A revealed action panel or popover button group is a useful pattern for users to access additional functionality while taking up minimal space. This overlay pattern comes with layout complexity, as the panel must remain tethered to a trigger element while adapting to viewport constraints. Traditionally, this required complex JavaScript libraries (like Popper.js or Floating UI) to calculate positions and handle collisions.
CSS Anchor Positioning provides a declarative, performance-optimized way to handle these relationships entirely in CSS, allowing browsers to manage the positioning and overflow logic natively.
1. Define the Button and Panel Relationship
The first step is to create a trigger button that opens the overlay container using the Popover API.
MANDATORY Accessibility Distinction: This pattern explicitly models a button group revealed inside a popover rather than a true ARIA menu. Do not apply role="menu" or role="menuitem" unless you fully implement the corresponding keyboard navigation contract (such as handling spatial arrow-key navigation between items). For the same reason, do not add aria-haspopup to the trigger — its value (menu, listbox, tree, grid, or the legacy true) is a promise that the target exposes a matching role, which this pattern does not.
<button popovertarget="action-panel">
Open Actions
</button>
<!-- Use the Popover API (`popover="auto"`) for the overlay to ensure it is placed in the top layer and handled accessibly by the browser. -->
<div id="action-panel" popover="auto" class="panel">
<button class="action-item" type="button">Edit</button>
</div>
This creates an implicit anchor association between the button and the panel, so that the panel can be positioned relative to the button natively without needing anchor-name on the button or position-anchor on the panel.
2. Positioning with position-area
Instead of manual top/left offsets, use position-area to place the target on a 3x3 grid relative to the anchor.
.panel {
/*
Position the panel below the anchor (block-end),
aligned to the start of the anchor and spanning to its end (span-inline-end).
*/
position-area: block-end span-inline-end;
/* Reset insets to allow the grid to take control */
inset: auto;
}
Prefer logical keywords (span-inline-end, block-start) over physical ones (left, top) to support RTL and different writing modes automatically.
MANDATORY: Do not mix physical and logical keywords in position-area.
3. Implement Edge-Resilience (Fallbacks)
To prevent the panel from being cut off at the edge of the screen, define "try tactics" that the browser should attempt if the default position overflows.
.panel {
/*
If the panel overflows the bottom, flip it to the top (flip-block).
If it overflows the inline edges, flip it horizontally (flip-inline).
If it overflows both (e.g., in a corner), flip on both axes (flip-block flip-inline).
*/
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}
If your dropdown or flyout includes a directional arrow or border that needs to change style depending on which fallback position was chosen, see Position Aware Tooltips. For declarative invocation of dialogs and popovers without JavaScript, see Declarative Dialog and Popover Control.
Fallback strategies
Fallbacks & browser support for Popover
The Popover API is mostly progressive enhancement, but its defining behaviors — top-layer promotion, light-dismiss, and popovertarget invocation — have no CSS-only equivalent. Older browsers need a polyfill, or a manual fallback if you would rather not ship one.
Polyfill: To support the popover attribute in older browsers, conditionally load @oddbird/popover-polyfill. MANDATORY: Feature detect by checking for the popover property on HTMLElement.prototype, and load the polyfill only when native support is missing — do NOT load it unconditionally.
With a bundler or import map:
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
Without a bundler, import from a CDN inside a <script type="module">:
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
Styling caveat: The polyfill cannot define the real :popover-open pseudo-class, so it applies a .\:popover-open class instead. MANDATORY: Combine the two with :is() or :where(), otherwise browsers that lack :popover-open discard the entire rule:
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
Alternatively, for a legacy fallback without a polyfill, use position: fixed and manually calculate coordinates via getBoundingClientRect() or rely on default positioning with inset: auto if that's acceptable for the use case.
To support browsers without anchor positioning, you must set a reasonable position. By default popovers are centered in the middle of the screen, which may work for your use case.
For some use cases, you may be able to use the @oddbird/css-anchor-positioning polyfill, which adds support for some anchor positioning use cases. It does not support implicit anchors, so you MUST add anchor names to the trigger. Additionally, position-area is not supported on popovers by the polyfill, so you MUST use anchor() on the desired insets.
<!-- MANDATORY: Conditionally install the anchor positioning polyfill -->
<script type="module">
if (!("anchorName" in document.documentElement.style)) {
await import("https://unpkg.com/@oddbird/css-anchor-positioning");
}
</script>
.panel {
/* Mandatory: use explicit anchor name */
position-anchor: --kebab-anchor;
/* Mandatory: use insets rather than position-area for positioning */
bottom: auto;
right: auto;
top: anchor(bottom);
left: anchor(left);
margin: 0;
}