Skip to content
Development

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 popover element) keeps its full size, acts as the query container, and takes position-area and the named @position-try rules.
  • The inner layer carries the background, the border and the menu items. It scales from a transform-origin that matches the corner nearest the button, the corner of whichever position-area cell 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.

Playground. Drag the button on the stage, or use Anchor X/Y and the corner presets. Nothing animates until you open the menu.

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) or CSS.supports in 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.