The WAI-ARIA menu button. There is no native menu element, so the platform gives the popover half (popover="auto": the top layer, light dismiss, Escape, nesting for submenus, placement next to the trigger, focus return) and the library gives the menu half: the current item, arrow keys, Home and End, typeahead, submenu keys and closing the menu when focus leaves it. And the Async React half: an item's action runs in a transition with data-pending on the item, and the menu closes in the commit that ends it, so a slow Server Function behind "Archive" shows its progress where the user clicked and the menu leaves with a view transition in every engine.
Last openChangeAction: (none yet)
"use client";
import { startTransition, useState } from "react";
import { Menu } from "@funstack/ui/menu";
import type { MenuOpenChangeDetails } from "@funstack/ui/menu";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { fakeSave } from "./fake-api.js";
// Each item's `action` runs in the item's own transition: the item shows data-pending (here a
// "…" label) while the fake server call runs, and the whole menu closes when it settles, with a
// view transition (`transition` on the Popup). "Move to" is a submenu: hover it, or press
// ArrowRight. A rejection reaches the Error Boundary inside the Popup and the menu stays open.
export function MenuRowActions() {
const [status, setStatus] = useState("Report.pdf");
const [last, setLast] = useState("(none yet)");
const [fail, setFail] = useState(false);
const record = (open: boolean, { reason }: MenuOpenChangeDetails) => {
setLast(`open=${open} reason="${reason}"`);
};
const done = (text: string) => () => startTransition(() => setStatus(text));
return (
<div className="demo-stack">
<div className="demo-row">
<span>{status}</span>
<Menu.Root openChangeAction={record}>
<Menu.Trigger className="demo-button" aria-label="Actions for Report.pdf">
{({ pending }) => (pending ? <span className="demo-spinner" aria-hidden /> : "⋯")}
</Menu.Trigger>
<Menu.Popup
className="demo-menu"
transition={{
"fsui-menu-open": "menu-in",
"fsui-menu-close": "menu-out",
default: "none",
}}
>
<DemoErrorBoundary>
<Menu.Item action={done("Report.pdf (renamed)")}>Rename</Menu.Item>
<Menu.Item
action={async () => {
await fakeSave("the archive", { ms: 1200, fail });
done("Report.pdf (archived)")();
}}
>
{({ pending }) => (pending ? "Archiving…" : "Archive")}
</Menu.Item>
<Menu.Item disabled>Share</Menu.Item>
<Menu.Separator />
<Menu.Root>
<Menu.SubmenuTrigger>Move to</Menu.SubmenuTrigger>
<Menu.Popup className="demo-menu">
<Menu.Item action={done("Report.pdf (in Inbox)")}>Inbox</Menu.Item>
<Menu.Item action={done("Report.pdf (in Later)")}>Later</Menu.Item>
</Menu.Popup>
</Menu.Root>
<Menu.Separator />
<Menu.Item className="demo-menu-danger" action={done("(deleted)")}>
Delete
</Menu.Item>
</DemoErrorBoundary>
</Menu.Popup>
</Menu.Root>
</div>
<label className="demo-row">
<input type="checkbox" checked={fail} onChange={(e) => setFail(e.target.checked)} />
Make Archive fail
</label>
<p className="demo-readout">Last openChangeAction: {last}</p>
</div>
);
}/* Menu: the Popup is a <div popover="auto" role="menu">. Open state is `:popover-open` on it;
items carry `data-pending`, `data-checked` and `aria-disabled`; the Trigger and SubmenuTrigger
carry `data-open` / `data-pending`. Hovering an item focuses it, so `:focus` is the hover
style too. */
.demo-menu {
/* The popover UA stylesheet sets `inset: 0; margin: auto`: reset both for anchored placement. */
margin: 0;
inset: auto;
min-inline-size: 12rem;
padding: 4px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
color: var(--color-text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
/* CSS enter / exit, used by dismissals (Escape, a click outside, Tab). */
opacity: 0;
transition:
opacity 120ms,
display 120ms allow-discrete,
overlay 120ms allow-discrete;
}
.demo-menu:popover-open {
opacity: 1;
}
@starting-style {
.demo-menu:popover-open {
opacity: 0;
}
}
.demo-menu[data-side="bottom"] {
margin-block-start: 4px;
}
/* The submenu gap. */
.demo-menu[data-side="right"] {
margin-inline-start: 2px;
}
.demo-menu [role^="menuitem"] {
display: flex;
align-items: center;
gap: 8px;
inline-size: 100%;
padding: 6px 10px;
border: 0;
border-radius: 4px;
background: none;
color: inherit;
font: inherit;
text-align: start;
cursor: pointer;
}
.demo-menu [role^="menuitem"]:focus {
outline: none;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.demo-menu [aria-disabled="true"] {
opacity: 0.45;
cursor: default;
}
.demo-menu [data-pending] {
cursor: progress;
}
/* A check column for checkbox and radio items. */
.demo-menu [role="menuitemcheckbox"]::before,
.demo-menu [role="menuitemradio"]::before {
content: "";
inline-size: 1em;
}
.demo-menu [role="menuitemcheckbox"][data-checked]::before,
.demo-menu [role="menuitemradio"][data-checked]::before {
content: "✓";
}
.demo-menu [role="menuitemcheckbox"][data-pending]::after,
.demo-menu [role="menuitemradio"][data-pending]::after {
content: "…";
margin-inline-start: auto;
}
/* A submenu arrow; flipped by the writing direction. */
.demo-menu [aria-haspopup="menu"]::after {
content: "›";
margin-inline-start: auto;
}
.demo-menu hr {
margin: 4px 0;
border: 0;
border-block-start: 1px solid var(--color-border);
}
.demo-menu-label {
padding: 4px 10px;
font-size: 0.8rem;
color: var(--color-text-muted, inherit);
}
.demo-menu-danger {
color: #dc2626;
}
/* Library-driven opens and closes (an item's activation): `transition` on the Popup. */
::view-transition-new(.menu-in) {
animation: 120ms ease-out both menu-in;
}
::view-transition-old(.menu-out) {
animation: 120ms ease-in both menu-out;
}
@keyframes menu-in {
from {
opacity: 0;
translate: 0 -4px;
}
}
@keyframes menu-out {
to {
opacity: 0;
translate: 0 -4px;
}
}
@media (prefers-reduced-motion: reduce) {
.demo-menu {
transition: none;
}
::view-transition-new(.menu-in),
::view-transition-old(.menu-out) {
animation: none;
}
}import { Menu } from "@funstack/ui/menu";
<Menu.Root> {/* no DOM: ids, open state */}
<Menu.Trigger>Actions</Menu.Trigger> {/* button[commandfor][aria-haspopup=menu] */}
<Menu.Popup> {/* div[popover=auto][role=menu] */}
<Menu.Item action={rename}>Rename</Menu.Item> {/* button[role=menuitem] */}
<Menu.Separator /> {/* hr */}
<Menu.Group> {/* div[role=group] */}
<Menu.GroupLabel>View</Menu.GroupLabel>
<Menu.CheckboxItem checked={dense} checkedChangeAction={saveDense}>Dense</Menu.CheckboxItem>
<Menu.RadioGroup value={sort} valueChangeAction={saveSort}>
<Menu.RadioItem value="name">By name</Menu.RadioItem>
<Menu.RadioItem value="date">By date</Menu.RadioItem>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Root> {/* a Root inside a Popup: a submenu */}
<Menu.SubmenuTrigger>Move to</Menu.SubmenuTrigger> {/* button[role=menuitem][aria-haspopup=menu] */}
<Menu.Popup>…</Menu.Popup> {/* opens to the right */}
</Menu.Root>
</Menu.Popup>
</Menu.Root>commandfor + command="toggle-popover": a click before hydration opens the (still empty) menu natively and the items fill in on hydration (reason: "platform").tabindex="-1" and type="button". render can substitute a link (render={<a href="/new" />}): Enter clicks it, so it navigates and runs the item's action.Menu.SubmenuTrigger, an item of the parent menu. Submenus are DOM descendants of their parent popup: nothing is portalled.Item with no action: activating it only closes. The Trigger names the menu (aria-labelledby).Click, Enter or Space on an Item runs its action in the item's own transition. The menu stays open while the action runs, with data-pending on the item, and closes once it settles, through the top-level Root (openChangeAction(false, { reason: "activate" }), then hidePopover() in the commit, with a view transition if the Popup has transition). A synchronous action closes in the same tick. A rejection leaves the menu open and reaches the nearest Error Boundary above the item. A second click re-runs the action; the first close wins. For "close now, work in the background" do not return the promise: action={() => { void archive(id); }}.
closeOnActivate={false} keeps the menu open (a "Refresh" item)."activate", each submenu Root "dismiss". The submenu disappears at once (it is hidden as a nested popover); the top-level menu animates.click(), so details.event is a click event for pointer and keyboard alike, and an item composed with another trigger (<Dialog.Trigger render={<Menu.Item closeOnActivate={false} />}>) runs its command.dense: false, show archived: true, sort: name
"use client";
import { startTransition, useState } from "react";
import { Menu } from "@funstack/ui/menu";
import { sleep } from "./fake-api.js";
// Checkbox and radio items are state owners with useOptimistic: aria-checked flips at once,
// data-pending shows while the (fake) save runs, and the menu stays open so several options can
// be changed in a row. Controlled here, following "update on success": the setState after the
// await is wrapped in startTransition.
export function MenuViewOptions() {
const [dense, setDense] = useState(false);
const [archived, setArchived] = useState(true);
const [sort, setSort] = useState("name");
return (
<div className="demo-stack">
<Menu.Root>
<Menu.Trigger className="demo-button">View</Menu.Trigger>
<Menu.Popup className="demo-menu" experimental_focusgroup>
<Menu.CheckboxItem
checked={dense}
checkedChangeAction={async (next) => {
await sleep(600);
startTransition(() => setDense(next));
}}
>
Dense rows
</Menu.CheckboxItem>
<Menu.CheckboxItem
checked={archived}
checkedChangeAction={async (next) => {
await sleep(600);
startTransition(() => setArchived(next));
}}
>
Show archived
</Menu.CheckboxItem>
<Menu.Separator />
<Menu.RadioGroup
value={sort}
valueChangeAction={async (next) => {
await sleep(600);
startTransition(() => setSort(next));
}}
>
<Menu.GroupLabel className="demo-menu-label">Sort by</Menu.GroupLabel>
<Menu.RadioItem value="name">Name</Menu.RadioItem>
<Menu.RadioItem value="modified">Last modified</Menu.RadioItem>
<Menu.RadioItem value="size">Size</Menu.RadioItem>
</Menu.RadioGroup>
</Menu.Popup>
</Menu.Root>
<p className="demo-readout">
dense: {String(dense)}, show archived: {String(archived)}, sort: {sort}
</p>
</div>
);
}/* Menu: the Popup is a <div popover="auto" role="menu">. Open state is `:popover-open` on it;
items carry `data-pending`, `data-checked` and `aria-disabled`; the Trigger and SubmenuTrigger
carry `data-open` / `data-pending`. Hovering an item focuses it, so `:focus` is the hover
style too. */
.demo-menu {
/* The popover UA stylesheet sets `inset: 0; margin: auto`: reset both for anchored placement. */
margin: 0;
inset: auto;
min-inline-size: 12rem;
padding: 4px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
color: var(--color-text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
/* CSS enter / exit, used by dismissals (Escape, a click outside, Tab). */
opacity: 0;
transition:
opacity 120ms,
display 120ms allow-discrete,
overlay 120ms allow-discrete;
}
.demo-menu:popover-open {
opacity: 1;
}
@starting-style {
.demo-menu:popover-open {
opacity: 0;
}
}
.demo-menu[data-side="bottom"] {
margin-block-start: 4px;
}
/* The submenu gap. */
.demo-menu[data-side="right"] {
margin-inline-start: 2px;
}
.demo-menu [role^="menuitem"] {
display: flex;
align-items: center;
gap: 8px;
inline-size: 100%;
padding: 6px 10px;
border: 0;
border-radius: 4px;
background: none;
color: inherit;
font: inherit;
text-align: start;
cursor: pointer;
}
.demo-menu [role^="menuitem"]:focus {
outline: none;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.demo-menu [aria-disabled="true"] {
opacity: 0.45;
cursor: default;
}
.demo-menu [data-pending] {
cursor: progress;
}
/* A check column for checkbox and radio items. */
.demo-menu [role="menuitemcheckbox"]::before,
.demo-menu [role="menuitemradio"]::before {
content: "";
inline-size: 1em;
}
.demo-menu [role="menuitemcheckbox"][data-checked]::before,
.demo-menu [role="menuitemradio"][data-checked]::before {
content: "✓";
}
.demo-menu [role="menuitemcheckbox"][data-pending]::after,
.demo-menu [role="menuitemradio"][data-pending]::after {
content: "…";
margin-inline-start: auto;
}
/* A submenu arrow; flipped by the writing direction. */
.demo-menu [aria-haspopup="menu"]::after {
content: "›";
margin-inline-start: auto;
}
.demo-menu hr {
margin: 4px 0;
border: 0;
border-block-start: 1px solid var(--color-border);
}
.demo-menu-label {
padding: 4px 10px;
font-size: 0.8rem;
color: var(--color-text-muted, inherit);
}
.demo-menu-danger {
color: #dc2626;
}
/* Library-driven opens and closes (an item's activation): `transition` on the Popup. */
::view-transition-new(.menu-in) {
animation: 120ms ease-out both menu-in;
}
::view-transition-old(.menu-out) {
animation: 120ms ease-in both menu-out;
}
@keyframes menu-in {
from {
opacity: 0;
translate: 0 -4px;
}
}
@keyframes menu-out {
to {
opacity: 0;
translate: 0 -4px;
}
}
@media (prefers-reduced-motion: reduce) {
.demo-menu {
transition: none;
}
::view-transition-new(.menu-in),
::view-transition-old(.menu-out) {
animation: none;
}
}CheckboxItem and RadioGroup are state owners like Checkbox and RadioGroup: aria-checked and data-checked flip at once, data-pending shows while the change action runs (on the item and on the RadioGroup), and a rejection rolls the check back. They keep the menu open by default (the APG's Space rule, and toggling several options is the point); closeOnActivate closes the menu after the change settles. Re-activating the selected radio item dispatches nothing.
Menu.Root| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | – | Controlled open state. undefined means uncontrolled. |
defaultOpen | boolean | – | Initial open state when uncontrolled. Shown after hydration. |
openChangeAction | ChangeAction<boolean, MenuOpenChangeDetails> | – | Awaited for true (the menu opens when it resolves and the items have rendered) and for the closes a part asks for (the Trigger, an item's activation, ArrowLeft in a submenu). A notification for dismissals (Escape, a click outside, Tab, the pointer leaving a hover-opened submenu, the parent menu closing). Runs in the Root's transition; may be a Server Function. details.reason: "activate", "dismiss" or "platform" (found open on hydration; event is undefined). |
loop | boolean | true | ArrowDown / ArrowUp wrap from the last item to the first and back. |
delay | number | 100 | Submenu only: milliseconds from hovering the SubmenuTrigger to the open. A development warning on a top-level Root. |
closeDelay | number | 150 | Submenu only: milliseconds from the pointer leaving the SubmenuTrigger and the submenu to the close. |
children | ReactNode | ((state: MenuRootState) => ReactNode) | – | The parts. The function form receives the optimistic state and must not suspend. |
MenuRootState: { open, pending, submenu }. open is optimistic; submenu is true for a Root rendered inside a Menu.Popup.
Menu.Trigger| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Native disabled. Owned, so a render element cannot re-enable it. |
className | string | ((state: MenuTriggerState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: MenuTriggerState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: MenuTriggerState) => ReactNode) | – | Rendered from optimistic state; must not suspend. |
render | ReactElement | ((props, state: MenuTriggerState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
MenuTriggerState: { open, pending, disabled }. Emits aria-expanded and data-open (optimistic), data-pending (an open or close of the Root in flight) and data-disabled. Owns type, id, disabled, commandfor, command, aria-haspopup, aria-expanded, onClick and onKeyDown. Use it in a top-level Root only.
Menu.Popup| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | null | "bottom" ("right" in a submenu) | Placement through inline position-area, with position-try-fallbacks flipping it (a submenu flips inline only). null emits no inline placement and no data-side / data-align. |
align | "start" | "center" | "end" | "start" | Alignment along the side. |
keepMounted | boolean | false | false: the items render only while open (items that suspend hold the open, with data-pending on the Trigger). true: they stay mounted in <Activity mode="hidden">. |
experimental_focusgroup | boolean | false | Renders focusgroup="menu wrap|nowrap". Where the engine implements it (Chromium 150+), the browser moves focus for ArrowUp / ArrowDown / Home / End and items render no tabindex; elsewhere nothing changes. Typeahead, activation, submenu keys and Tab stay in JS. |
transition | ViewTransitionClass | – | Forwarded to a <ViewTransition> around the popup element: animates the open and every library-driven close (an item's activation, the Trigger, ArrowLeft, a hover close, controlled) in every engine. Dismissals animate with CSS only. |
className | string | ((state: MenuPopupState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: MenuPopupState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: MenuPopupState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: MenuPopupState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
MenuPopupState: { open, side, align, submenu } (committed open). Owns id, popover, role="menu", aria-labelledby (merged), tabIndex={-1}, focusgroup (with the flag), onKeyDown, onBlur, the submenu's pointer handlers, and data-side / data-align. Style it with :popover-open; it emits no data-open. Render a <div>, not a <dialog>.
Menu.Item| Prop | Type | Default | Description |
|---|---|---|---|
action | Action<[details: MenuItemDetails]> | – | Runs in the item's own transition (data-pending on the item); the menu closes when it settles. May be a Server Function (details.event is virtual). A rejection keeps the menu open and reaches the nearest Error Boundary above the item. |
closeOnActivate | boolean | true | Close the whole menu once action settles. |
disabled | boolean | false | Renders aria-disabled="true" and data-disabled, never the native attribute: a disabled item stays focusable (the APG) and is reached by the arrow keys and typeahead, but never activates. |
label | string | – | Text that typeahead matches when the item's text content is not representative (an icon). Rendered as data-typeahead. |
className | string | ((state: MenuItemState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: MenuItemState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: MenuItemState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: MenuItemState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
MenuItemState: { pending, disabled }. Owns type (on a button), role, tabIndex, disabled (always absent), aria-disabled, onClick, onKeyDown, onPointerMove, data-pending, data-disabled and data-typeahead.
Menu.CheckboxItem| Prop | Type | Default | Description |
|---|---|---|---|
checked | boolean | – | Controlled checked state. undefined means uncontrolled. |
defaultChecked | boolean | false | Initial checked state when uncontrolled. |
checkedChangeAction | ChangeAction<boolean> | – | Awaited in the item's transition; aria-checked flips at once and rolls back on rejection. May be a Server Function. |
closeOnActivate | boolean | false | Close the whole menu once the change settles. |
disabled | boolean | false | Renders aria-disabled="true" and data-disabled, never the native attribute: a disabled item stays focusable (the APG) and is reached by the arrow keys and typeahead, but never activates. |
label | string | – | Text that typeahead matches when the item's text content is not representative (an icon). Rendered as data-typeahead. |
className | string | ((state: MenuCheckboxItemState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: MenuCheckboxItemState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: MenuCheckboxItemState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: MenuCheckboxItemState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
MenuCheckboxItemState: { checked, pending, disabled }. Emits role="menuitemcheckbox", aria-checked and data-checked (optimistic). Transition types fsui-menu with fsui-menu-check or fsui-menu-uncheck.
Menu.RadioGroup and Menu.RadioItem| Prop | Type | Default | Description |
|---|---|---|---|
value | string | null | – | RadioGroup. Controlled value; null selects nothing, undefined means uncontrolled. |
defaultValue | string | null | null | RadioGroup. Initial value when uncontrolled. |
valueChangeAction | ChangeAction<string> | – | RadioGroup. Receives the chosen item's value, awaited in the group's transition. May be a Server Function. |
value | string | – | RadioItem (required). The value this item selects. |
closeOnActivate | boolean | false | RadioItem. Close the whole menu once the change settles (also when the item was already selected). |
disabled | boolean | false | Renders aria-disabled="true" and data-disabled, never the native attribute: a disabled item stays focusable (the APG) and is reached by the arrow keys and typeahead, but never activates. |
label | string | – | Text that typeahead matches when the item's text content is not representative (an icon). Rendered as data-typeahead. |
className | string | ((state: MenuRadioItemState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: MenuRadioItemState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: MenuRadioItemState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: MenuRadioItemState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
MenuRadioGroupState: { value, pending }; the group renders <div role="group"> named by a GroupLabel inside it, with data-pending. MenuRadioItemState: { checked, pending, disabled }; pending is true on the item being selected. A RadioItem outside a RadioGroup throws. Transition types fsui-menu, fsui-menu-select.
Menu.Group, Menu.GroupLabel, Menu.SeparatorGroup is a <div role="group"> labelled by its GroupLabel (a <div> with the owned id), which also names a RadioGroup. Separator is an <hr> (implicit role="separator"); give a render element role="separator" yourself. All take className, style, render and (except Separator) children; they have no state.
Menu.SubmenuTrigger| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | aria-disabled: focusable, never opens. Not the native attribute (it is an item). |
label | string | – | Typeahead text, as on Menu.Item. |
className | string | ((state: MenuSubmenuTriggerState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: MenuSubmenuTriggerState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: MenuSubmenuTriggerState) => ReactNode) | – | Rendered from optimistic state; must not suspend. |
render | ReactElement | ((props, state: MenuSubmenuTriggerState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
MenuSubmenuTriggerState: { open, pending, disabled }, as the Trigger. Both an item of the parent menu (role="menuitem", roving tabindex, typeahead) and the trigger of its Root (commandfor, aria-haspopup="menu", aria-expanded, data-open, data-pending while items that suspend load). Use it in a submenu Root only.
| Key | Where | Behaviour |
|---|---|---|
| Enter, Space, ArrowDown | Trigger | Open with the first enabled item focused (ArrowDown on an open menu focuses it) |
| ArrowUp | Trigger | Open with the last enabled item focused |
| ArrowDown / ArrowUp | item | Next / previous item, disabled ones included; wraps unless loop={false} |
| Home / End | item | First / last item |
| A printable character | item | Typeahead: the next item starting with it; repeat to cycle; type a prefix within 500 ms |
| Enter / Space | item | Activate (Space is a character while typeahead is active) |
| ArrowRight | SubmenuTrigger | Open the submenu with its first item focused |
| ArrowLeft | item in a submenu | Close the submenu; focus returns to its trigger |
| Escape | anywhere | Close the innermost menu; focus returns to its trigger (the library does it for a submenu) |
| Tab / Shift+Tab | item | Close the whole menu at once and move on (Shift+Tab lands on the Trigger) |
direction: rtl ArrowLeft opens and ArrowRight closes a submenu.aria-activedescendant). Every open focuses the first item, pointer opens included (no focus ring then); a submenu opened by hover keeps focus on its trigger. Moving the pointer over an item focuses it, so keyboard and pointer always agree on the current item: style :focus as the hover state.delay ms (100) after the pointer enters its trigger and closes closeDelay ms (150) after the pointer leaves both, unless keyboard focus is inside it. Touch never opens on hover.openChangeAction: a menu must not linger once focus has moved on. It is notified as "dismiss".aria-haspopup="menu" and aria-expanded; the Popup role="menu" and aria-labelledby pointing at its trigger; items their roles and aria-checked / aria-disabled.Style the popup with :popover-open and the items with [role^="menuitem"], :focus, [aria-disabled="true"], [data-pending] and [data-checked]. Dismissals animate with CSS (@starting-style and transition-behavior: allow-discrete; Firefox hides a popover instantly). Library-driven opens and closes animate with transition on the Popup in every engine, transition types fsui-menu plus fsui-menu-open or fsui-menu-close. Keep them short: the page takes no pointer events during a view transition.
<Menu.Popup transition={{ "fsui-menu-open": "menu-in", "fsui-menu-close": "menu-out", default: "none" }}>The items render from committed state, so items that suspend (a submenu of recent files read with use()) hold the open: the menu (or submenu) stays closed with data-pending on its trigger, then opens once, complete, with the first item focused.
<Menu.Root>
<Menu.SubmenuTrigger>Open recent</Menu.SubmenuTrigger>
<Menu.Popup>
<RecentFiles /> {/* use(recentFiles): the submenu opens when ready */}
</Menu.Popup>
</Menu.Root>experimental_focusgroup takes the native path in Chromium only; other engines keep the JS arrow keys (identical behaviour).position-try-fallbacks can hang (WebKit bug 302008). If a submenu hits it, give its Popup side={null} and place it in CSS.