A popover menu that morphs from its anchor, with no guessing in JavaScript
The Popover API handles opening, closing and the top layer, with Escape and a click outside to close. Placement belongs to CSS anchor positioning: position-area picks the default cell on the grid around the button, and position-try-fallbacks lists what to try when space runs out. A button with popovertarget gives the panel an implicit anchor. The playground below also sets a named anchor-name / position-anchor, so the menu always has an anchor even when it is not opened by a direct click on the button. Anchor positioning level 1 can flip the menu, but once it has flipped, CSS has no way to know which side it landed on and style it to match.
Anchored container queries add exactly that signal: set container-type: anchored on the anchored element, then query it with @container anchored(fallback: --top-left). The query applies only to the container's descendants, never to the container itself. That is why the menu is split into two layers.
Two layers: the outer one holds the space, the inner one morphs
- The outer layer (the
popoverelement) keeps its full size, acts as the query container, and takesposition-areaand the named@position-tryrules. - The inner layer carries the background, the border and the menu items. It scales from a
transform-originthat matches the corner nearest the button, the corner of whicheverposition-areacell is active.
The outer layer never shrinks with the animation, so layout and hit-testing stay stable. The inner layer is only the picture that grows into place. @starting-style sets the starting point of each opening; transition-behavior: allow-discrete lets display and overlay transition too.
The key pattern
@position-try --top-left {
position-area: top left;
}
#menu-trigger {
anchor-name: --menu-anchor;
}
.anchored-popover {
position-anchor: --menu-anchor;
position-area: bottom right;
position-try-fallbacks: --top-left, --top-right, --bottom-left;
container-type: anchored; /* only inside @supports */
display: grid; /* the outer layer keeps its full size */
}
.anchored-popover-face {
transform-origin: top left; /* default: the panel sits below and right of the button */
}
@container anchored(fallback: --top-left) {
.anchored-popover-face { transform-origin: bottom right; }
}<button id="menu-trigger" popovertarget="menu-panel">Open menu</button>
<div id="menu-panel" class="anchored-popover" popover>
<div class="anchored-popover-face">…menu items…</div>
</div>Wrap the scaling and every @container anchored(...) in @supports (container-type: anchored). A browser without support still opens the menu; it only loses the corner-aware morph.
Try it in the playground
The frame below keeps the controls apart from the stage. The stage is a nested iframe, so the popover and position-try see only the stage's viewport and never cover the controls. Adjust the anchor position, duration, starting scale, easing, gap and width; click a corner preset; Replay or Reset; turn on reduced motion (on top of prefers-reduced-motion). The status bar shows the mode actually running (CSS native or JS fallback), the computed transform-origin and the fallback inferred from it.
Why a JS fallback, and only when needed
When CSS.supports('container-type: anchored') is true, CSS decides the origin; the playground only reads getComputedStyle to display it and never overrides it. When it is false, the script estimates which side has room and sets transform-origin (and position-area if needed). The interface names the mode, so nobody mistakes the polyfill for the native version.
Notes on support and accessibility
- Test support with
@supports (container-type: anchored)orCSS.supportsin the browsers you target, rather than relying on version numbers. - Basic anchoring (
position-area,@position-try, the Popover API) and anchored container queries are two separate layers: a browser can ship the first without the second. - A query container cannot style itself, so the visuals always live on a descendant.
- Respect
prefers-reduced-motion; the playground adds a checkbox to try it by hand. - The menu uses the Popover API, so Escape and a click outside both close it, and the menu items take keyboard focus.
In short. The outer layer holds the space and reports the fallback. The inner layer morphs from the right corner. Use native CSS where anchored container queries exist; fall back to JS only where they don’t, and say so in the interface.