Flyout
Positioned surface attached to a trigger, commonly used for action lists and small panels.
Related terms: popover, dropdown, dropdown menu, action menu, context menu, bottom sheet, action sheet, anchored panel.
Class reference
| Class | Kind | Description |
|---|---|---|
.flyout-trigger | Composition | Positioning wrapper for trigger and panel. |
.stretch | Modifier | Spans a .flyout-trigger to its container. |
.flyout | Component | Floating surface, positioned by JavaScript. |
.menu | Component | Action-list primitive. |
.menu-item | Component | One action row; joins roving focus. |
.menu-item-icon | Slot | Fixed leading column for an icon. |
.menu-item-text | Slot | Flexible label column; truncates. |
.menu-item-end | Slot | Trailing shortcut, badge or status. |
.menu has a strict .menu > li > .menu-item anatomy.
| .menu-label | Variant | Muted section heading inside a menu; not interactive. |
| .menu-separator | Variant | Divider between menu groups (<hr>). |
| .sm / .lg | Size | Smaller or larger menu rows and type. |
Usage
Flyout and context menu share one action-surface runtime:
- Flyout = a visible trigger opens a surface.
- Context menu = right click or a keyboard context action opens that same surface.
- Flyouts and context menus remain anchored, non-modal popovers at every viewport size.
One surface is open at a time. Opening any of them closes the others, so a trigger placed inside an open panel replaces that panel instead of opening a second level. Submenus and other nested panels are outside the current contract.
Trigger wiring
The runtime resolves the panel from the trigger, so the wiring is part of the contract:
- Put
data-enhance="flyout"on the trigger (the button, link, or other clickable element), never on a wrapper around it. - The trigger references its panel with
aria-controls="…"and the panel owns the matchingid. The runtime readsaria-controlsto find the panel — a trigger without the pairing never opens anything. - Wrap the trigger and panel in
.flyout-trigger. It is the positioning context for the no-JS fallback: without it the panel lays out against the nearest positioned ancestor (or the page edge) instead of under the trigger. - Start the panel with
hiddenso it does not flash open before the runtime takes it over. The runtime then removes the attribute: a closed panel is hidden by the popover transport, and[hidden]would outrank it.
<div class="flyout-trigger">
<button data-enhance="flyout"
aria-expanded="false"
aria-controls="my-panel">
Menu
</button>
<div class="flyout" id="my-panel" hidden>…</div>
</div>
For a modal bottom sheet or action sheet, use dialog.drawer; use
dialog.modal for a centered modal. Those components own modal focus,
dismissal, scroll locking, and backdrop behavior explicitly.
Use data-flyout-placement for the preferred anchored placement. It accepts the
placement strings supported by the floating runtime, such as bottom-start,
bottom-end, top-start, right, or left.
Use data-flyout-distance for the trigger gap in pixels. The default is 4.
Native popover lifecycle
The .flyout presentation also composes with an element carrying popover.
Actual neutralizes the native popover geometry that conflicts with anchored
positioning and recognizes :popover-open as the visible state. The browser or
another library may therefore own opening, dismissal, and focus without
requiring .is-open or [hidden].
Actual CSS does not position that independently managed popover. Its positioner
must couple fixed positioning with viewport coordinates or absolute positioning
with document coordinates (and may write --available-height and
--surface-anchor-width). The built-in surface.js runtime remains the full
fallback when Actual owns the lifecycle too. A bare [popover] without
.flyout or .tooltip keeps its platform appearance.
The Popover lifecycle is guaranteed across Minimal, so choosing it is a
behavior decision rather than a support one: native light dismiss closes on
outside clicks only, which cannot express data-flyout-auto-close. The two
lifecycles are alternatives, not layers.
surface.js itself uses popover="manual" as its transport — the panel is
promoted to the top layer where it stands, so a scoped theme, density, or
custom property still reaches it. That transport supplies no behavior, so the
runtime keeps owning dismissal, Escape ordering, focus and placement.
Choose one lifecycle owner
The Actual runtime path, where surface.js owns opening, dismissal, focus and
placement:
<div class="flyout-trigger">
<button data-enhance="flyout" aria-controls="actions" aria-expanded="false">
Actions
</button>
<ul id="actions" class="flyout menu" hidden>
<li><button class="menu-item" type="button">Rename</button></li>
</ul>
</div>
The native path, where the browser owns the lifecycle and the top layer:
<button popovertarget="actions">Actions</button>
<ul id="actions" class="flyout menu" popover>
<li><button class="menu-item" type="button">Rename</button></li>
</ul>
Do not combine them. surface.js writes popover="manual" on every panel it
manages and drives showPopover() / hidePopover() itself, so a
popover="auto" panel handed to the runtime has its mode overwritten — the
runtime keeps one lifecycle owner rather than letting native light dismiss
compete with data-flyout-auto-close. Keep popovertarget for panels the
runtime does not enhance.
The native path still needs a positioner. popovertarget gives the panel an
implicit anchor reference to its invoker, but it does not place it: Actual's
geometry reset clears the UA's centering, so an unpositioned native flyout is
laid out at its static position rather than under its trigger. Supply
coordinates the same way surface.js does:
import { autoUpdate, reposition } from "@lekoala/floating";
const trigger = document.querySelector("[popovertarget='actions']");
const panel = document.getElementById("actions");
// One tracker at a time. autoUpdate returns its own cleanup; releasing it
// before every state change keeps repeated opens from stacking trackers, and
// stops the scroll/resize work as soon as the panel closes.
let stop;
panel.addEventListener("toggle", (event) => {
stop?.();
stop = undefined;
if (event.newState !== "open") return;
stop = autoUpdate(trigger, panel, () => {
reposition(trigger, panel, { placement: "bottom-start", distance: 4 });
});
});
CSS Anchor Positioning will eventually cover the simple anchored case from the stylesheet alone, using that implicit anchor reference. It is still only partially supported and is not the documented recipe yet.
When surface.js owns the lifecycle, use data-flyout-auto-close if the
default click dismissal behavior is not right. Escape still follows the shared
surface lifecycle. These options do not reach a natively managed
popover="auto", whose dismissal belongs to the browser.
trueis the default. Clicks inside or outside close the flyout.insidecloses on inside clicks only.outsidecloses on outside click only.falsedisables automatic click closing.
These values follow Bootstrap's auto-close vocabulary. They apply to action
menus and rich panels alike. Add data-flyout-close to a control inside the
flyout when that specific control must close it regardless of the automatic
policy. This is useful for an Apply or Done action in an outside or false
panel. The trigger and Escape continue to close the flyout in every mode.
<div class="flyout"
id="filters"
data-flyout-auto-close="outside"
hidden>
<label><input type="checkbox"> Available only</label>
<button type="button" data-flyout-close>Done</button>
</div>
Flyout covers two distinct patterns, detected by the <menu> element or
.menu class on the panel:
Action list
A list of actions the user can take: sign out, copy, delete.
- Flyout triggers are opt-in: add
data-enhance="flyout"to the trigger. - Wrap the trigger and flyout in
.flyout-triggerwhen the flyout should have a local absolute-position fallback before JavaScript positions it. Add.stretchwhen the trigger must span its container, such as the last row of a full-width sidebar nav list. - Use
<menu class="flyout menu">with strict anatomy:.menu > li > .menu-item. Items must carry the.menu-itemclass to participate in directional keyboard navigation. ArrowUp/Down and Home/End move focus without rewriting their normal tab stops. - Use
.menu-labelon alifor a muted section heading inside a menu (e.g. a group title before its items). It is non-interactive and does not participate in roving focus. Use.menu-separator(a plain<hr>) between groups. - Items are regular
<button>or<a>elements. - Use
.smor.lgto scale one flyout; use a density context for surrounding UI. - Add
role="menu"/role="menuitem"only when you intentionally need the ARIA menu pattern described below.
<div class="flyout-trigger">
<button class="btn outline"
type="button"
data-enhance="flyout"
aria-expanded="false"
aria-controls="account-actions"
id="account-flyout-trigger">
Account
<i class="ti ti-chevron-down" aria-hidden="true"></i>
</button>
<menu class="flyout sm menu"
id="account-actions"
aria-labelledby="account-flyout-trigger"
hidden>
<li><button class="menu-item" type="button">Profile</button></li>
<li><button class="menu-item" type="button">Settings</button></li>
<hr class="menu-separator">
<li><button class="menu-item danger" type="button">Sign out</button></li>
</menu>
</div>
ARIA menu pattern
Adding role="menu" opts the action list into a composite with one roving tab
stop. Its usable .menu-item children with role="menuitem",
role="menuitemcheckbox", or role="menuitemradio" participate in vertical,
wrapping Arrow/Home/End navigation. Items without one of those roles are not
part of the focus group. Plain .menu action lists keep every item's normal
tab stop instead.
The runtime owns only focus movement and tabindex. The application still owns
command state and updates attributes such as aria-checked.
Linting role="menu"
Biome's a11y/noNoninteractiveElementToInteractiveRole reports
<menu role="menu">, and the fix it offers — remove the role — dismantles the
pattern. It is a port of
jsx-a11y/no-noninteractive-element-to-interactive-role that lost the source
rule's default allowlist, and that allowlist exists to permit exactly this
markup: the source documents <ul role="menu"> as valid, matching the
WAI-ARIA APG menu button pattern. Every ARIA composite built on a list is
affected, not only menus — listbox, tablist, menubar, tree, grid.
Until it is fixed upstream, turn the rule off:
{
"linter": {
"rules": {
"a11y": { "noNoninteractiveElementToInteractiveRole": "off" }
}
}
}
One line, and no biome-ignore comment above every menu in your markup. The
rest of the a11y group stays on and is worth keeping — with just this rule
off, the menu anatomy above lints clean.
Accessibility decisions records which patterns Biome rejects, and which of its findings were right.
Rich items and selection state
Wrap rich item content in the three optional slots when a menu needs aligned
icons or trailing metadata. Keep decorative icons hidden from assistive
technology. Plain text directly inside .menu-item remains valid for simple
actions.
<li>
<button class="menu-item" type="button" role="menuitem">
<span class="menu-item-icon" aria-hidden="true">…</span>
<span class="menu-item-text">Duplicate</span>
<span class="menu-item-end"><kbd>⌘D</kbd></span>
</button>
</li>
Use role="menuitemcheckbox" for independent options and
role="menuitemradio" for an exclusive choice. Put aria-checked on the menu
item itself; do not nest a checkbox, radio, or switch inside it. The runtime
recognizes all three menu-item roles for keyboard activation and the CSS draws
their state indicator. The application remains responsible for updating
aria-checked, just as it owns the state behind the command.
<li>
<button class="menu-item"
type="button"
role="menuitemcheckbox"
aria-checked="true">
<span class="menu-item-text">Show weekends</span>
</button>
</li>
Badges and shortcuts are passive trailing metadata. If a trailing pin, switch,
or button is independently interactive, use a rich flyout panel with normal
Tab navigation instead of role="menu": a menu item must remain one command.
Nav panel
A panel of links to other pages: product categories, docs sections. Nav panels
can be multi-column with <section> / <ul> groups.
- Items are regular
<a href>links, notrole="menuitem". - No roving focus — ArrowDown/Enter open the panel and focus the first focusable descendant. Tab from an open trigger also enters the panel. There is no arrow-key navigation between items inside.
- Just a toggle with outside-click and Escape dismissal.
- Use grid utilities, such as
.grid-3, for wider multi-column flyouts.
<nav aria-label="Main navigation">
<ul class="list-reset cluster">
<li class="flyout-trigger">
<button class="btn ghost"
type="button"
data-enhance="flyout"
aria-expanded="false"
aria-controls="products-panel">
Products
<i class="ti ti-chevron-down" aria-hidden="true"></i>
</button>
<div class="flyout"
id="products-panel"
aria-label="Products"
hidden>
<section aria-labelledby="products-design">
<h3 id="products-design">Design</h3>
<ul>
<li><a href="/figma">Figma integration</a></li>
<li><a href="/tokens">Design tokens</a></li>
</ul>
</section>
<section aria-labelledby="products-dev">
<h3 id="products-dev">Development</h3>
<ul>
<li><a href="/components">Components</a></li>
<li><a href="/api">API</a></li>
</ul>
</section>
<footer>
<a href="/pricing" class="btn primary">See pricing</a>
</footer>
</div>
</li>
<li><a href="/about" class="btn ghost">About</a></li>
<li><a href="/contact" class="btn ghost">Contact</a></li>
</ul>
</nav>
Mega menu
Use class="flyout grid-3" when a nav panel needs multiple link groups. Keep
links as regular anchors. The panel remains an anchored popover on mobile; use
a separate dialog.drawer when the content needs a modal mobile presentation.
<nav aria-label="Product navigation">
<ul class="list-reset cluster">
<li class="flyout-trigger">
<button class="btn ghost"
type="button"
data-enhance="flyout"
aria-expanded="false"
aria-controls="product-mega-menu">
Platform
<i class="ti ti-chevron-down" aria-hidden="true"></i>
</button>
<div class="flyout grid-3"
id="product-mega-menu"
aria-label="Platform"
style="--flyout-inline-size: 42rem; --flyout-max-inline-size: 42rem"
hidden>
<section aria-labelledby="mega-design">
<h3 id="mega-design">Design</h3>
<ul>
<li><a href="/figma">Figma integration</a></li>
<li><a href="/tokens">Design tokens</a></li>
<li><a href="/handoff">Developer handoff</a></li>
</ul>
</section>
<section aria-labelledby="mega-develop">
<h3 id="mega-develop">Development</h3>
<ul>
<li><a href="/components">Components</a></li>
<li><a href="/api">API</a></li>
<li><a href="/changelog">Changelog</a></li>
</ul>
</section>
<section aria-labelledby="mega-operate">
<h3 id="mega-operate">Operate</h3>
<ul>
<li><a href="/analytics">Analytics</a></li>
<li><a href="/security">Security</a></li>
<li><a href="/support">Support</a></li>
</ul>
</section>
<footer class="cluster"
style="grid-column: 1 / -1; --cluster-justify: flex-end; border-block-start: var(--border-width) solid var(--border)">
<a href="/pricing" class="btn primary">See pricing</a>
</footer>
</div>
</li>
</ul>
</nav>
Context menu
Context menus use the same .menu > li > .menu-item presentation primitive as
action-list flyouts.
Put data-context-menu on the smallest unit the actions operate on: a file row,
card, or canvas item. Several units may reference the same
<menu class="flyout">. An explicit trigger button inside the unit, marked with
data-context-menu-trigger and aria-controls, opens the menu through the same
context-aware path — its aria-controls must match the data-context-menu id
of the host.
Before opening, the context element dispatches the cancelable
actual:context-menu event. Its detail contains the shared menu, the owning
context, the exact origin, and the opening trigger (pointer, touch,
keyboard, or button). Use it to tailor the static menu to the selected item,
or cancel the event to keep it closed. contextFor(menu) from
actual-css/js/context-menu returns that detail while handling a menu action.
Context menus dismiss when a new user interaction scrolls their surrounding
content. Scroll caused by opening focus, and scrolling inside the menu itself,
do not dismiss it.
Add data-context-menu-scope only when the flyout should stay inside a specific
region. Empty or self constrains to the target itself, parent constrains to
the parent, and a selector constrains to the closest matching ancestor or first
matching element. The scope also constrains the available height, so omit it
when the menu is allowed to escape the card or list item.
Long press is opt-in with data-context-menu-long-press. Empty uses the default
delay; a number sets the delay in milliseconds.
Right click a detail below, press the context-menu key, or use More.
<div class="card stack"
id="file-card"
data-context-menu="file-actions"
data-context-menu-long-press
tabindex="0"
style="min-block-size: 12rem;">
<div class="cluster justify-content-space-between items-center">
<strong>File.pdf</strong>
<button class="btn ghost"
type="button"
data-context-menu-trigger
aria-controls="file-actions"
id="file-actions-trigger">
More
</button>
</div>
<p class="muted">Right click a detail below, press the context-menu key, or use More.</p>
<div class="cluster">
<span class="badge" data-context-item="File name">File.pdf</span>
<span class="badge" data-context-item="Owner">Design team</span>
</div>
<output id="context-result" class="muted" aria-live="polite">No context selected.</output>
<menu class="flyout menu"
id="file-actions"
aria-labelledby="file-actions-trigger"
hidden>
<li><button class="menu-item" type="button">Open</button></li>
<li><button class="menu-item" type="button">Rename</button></li>
<hr class="menu-separator">
<li><button class="menu-item danger" type="button">Delete</button></li>
</menu>
</div>
<script>
const card = document.getElementById("file-card");
const result = document.getElementById("context-result");
card.addEventListener("actual:context-menu", (event) => {
const item = event.detail.origin.closest?.("[data-context-item]");
const label = item?.dataset.contextItem ?? "the card";
result.textContent = `Menu opened for ${label} (${event.detail.trigger}).`;
});
</script>
CSS hooks
--flyout-inline-size— panel width.--flyout-max-inline-size— panel width cap.--menu-item-size— minimum row height of.menu-item.--menu-item-icon-size— shared leading-column width for icons and checked-state indicators.
--available-height and --surface-anchor-width are written by the positioner
at runtime, not set by the author. See the JavaScript runtime documentation.