FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup

Resources

  • Roadmap & changelog
  • GitHub ↗

Menu

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.

Report.pdf

Last openChangeAction: (none yet)

Show code
MenuRowActions.tsx
"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.css
/* 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;
  }
}

Anatomy

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>
  • Always library mode. A menu cannot follow the APG without JavaScript, so there is no JS-free mode. The Trigger still renders commandfor + command="toggle-popover": a click before hydration opens the (still empty) menu natively and the items fill in on hydration (reason: "platform").
  • Items are buttons with explicit roles, 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.
  • A submenu is a nested Root whose trigger is a Menu.SubmenuTrigger, an item of the parent menu. Submenus are DOM descendants of their parent popup: nothing is portalled.
  • No Portal, Positioner, Arrow or Close part. A "Cancel" item is an Item with no action: activating it only closes. The Trigger names the menu (aria-labelledby).

Items, actions and closing

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).
  • Activating an item in a submenu closes the whole stack: the top-level Root gets "activate", each submenu Root "dismiss". The submenu disappears at once (it is hidden as a nested popover); the top-level menu animates.
  • If the menu was dismissed and opened again while an action ran, that action's close is dropped: it belonged to the previous opening.
  • Keyboard activation goes through the item's 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.

Checkbox and radio items

dense: false, show archived: true, sort: name

Show code
MenuViewOptions.tsx
"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.css
/* 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.

Uncontrolled state lives with the items

By default the items unmount when the menu closes, and uncontrolled checkbox and radio state goes with them. Control them (as above), or give the Popup keepMounted.

Props

Menu.Root

PropTypeDefaultDescription
openboolean–Controlled open state. undefined means uncontrolled.
defaultOpenboolean–Initial open state when uncontrolled. Shown after hydration.
openChangeActionChangeAction<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).
loopbooleantrueArrowDown / ArrowUp wrap from the last item to the first and back.
delaynumber100Submenu only: milliseconds from hovering the SubmenuTrigger to the open. A development warning on a top-level Root.
closeDelaynumber150Submenu only: milliseconds from the pointer leaving the SubmenuTrigger and the submenu to the close.
childrenReactNode | ((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

PropTypeDefaultDescription
disabledbooleanfalseNative disabled. Owned, so a render element cannot re-enable it.
classNamestring | ((state: MenuTriggerState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: MenuTriggerState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: MenuTriggerState) => ReactNode)–Rendered from optimistic state; must not suspend.
renderReactElement | ((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

PropTypeDefaultDescription
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.
keepMountedbooleanfalsefalse: 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_focusgroupbooleanfalseRenders 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.
transitionViewTransitionClass–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.
classNamestring | ((state: MenuPopupState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: MenuPopupState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: MenuPopupState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
actionAction<[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.
closeOnActivatebooleantrueClose the whole menu once action settles.
disabledbooleanfalseRenders 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.
labelstring–Text that typeahead matches when the item's text content is not representative (an icon). Rendered as data-typeahead.
classNamestring | ((state: MenuItemState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: MenuItemState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: MenuItemState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
checkedboolean–Controlled checked state. undefined means uncontrolled.
defaultCheckedbooleanfalseInitial checked state when uncontrolled.
checkedChangeActionChangeAction<boolean>–Awaited in the item's transition; aria-checked flips at once and rolls back on rejection. May be a Server Function.
closeOnActivatebooleanfalseClose the whole menu once the change settles.
disabledbooleanfalseRenders 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.
labelstring–Text that typeahead matches when the item's text content is not representative (an icon). Rendered as data-typeahead.
classNamestring | ((state: MenuCheckboxItemState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: MenuCheckboxItemState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: MenuCheckboxItemState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
valuestring | null–RadioGroup. Controlled value; null selects nothing, undefined means uncontrolled.
defaultValuestring | nullnullRadioGroup. Initial value when uncontrolled.
valueChangeActionChangeAction<string>–RadioGroup. Receives the chosen item's value, awaited in the group's transition. May be a Server Function.
valuestring–RadioItem (required). The value this item selects.
closeOnActivatebooleanfalseRadioItem. Close the whole menu once the change settles (also when the item was already selected).
disabledbooleanfalseRenders 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.
labelstring–Text that typeahead matches when the item's text content is not representative (an icon). Rendered as data-typeahead.
classNamestring | ((state: MenuRadioItemState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: MenuRadioItemState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: MenuRadioItemState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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.Separator

Group 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

PropTypeDefaultDescription
disabledbooleanfalsearia-disabled: focusable, never opens. Not the native attribute (it is an item).
labelstring–Typeahead text, as on Menu.Item.
classNamestring | ((state: MenuSubmenuTriggerState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: MenuSubmenuTriggerState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: MenuSubmenuTriggerState) => ReactNode)–Rendered from optimistic state; must not suspend.
renderReactElement | ((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.

Keyboard, focus and ARIA

KeyWhereBehaviour
Enter, Space, ArrowDownTriggerOpen with the first enabled item focused (ArrowDown on an open menu focuses it)
ArrowUpTriggerOpen with the last enabled item focused
ArrowDown / ArrowUpitemNext / previous item, disabled ones included; wraps unless loop={false}
Home / EnditemFirst / last item
A printable characteritemTypeahead: the next item starting with it; repeat to cycle; type a prefix within 500 ms
Enter / SpaceitemActivate (Space is a character while typeahead is active)
ArrowRightSubmenuTriggerOpen the submenu with its first item focused
ArrowLeftitem in a submenuClose the submenu; focus returns to its trigger
EscapeanywhereClose the innermost menu; focus returns to its trigger (the library does it for a submenu)
Tab / Shift+TabitemClose the whole menu at once and move on (Shift+Tab lands on the Trigger)
  • Under direction: rtl ArrowLeft opens and ArrowRight closes a submenu.
  • Focus is real DOM focus on items (no 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.
  • Hover intent for submenus: the submenu opens 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.
  • Tab closes immediately, without awaiting openChangeAction: a menu must not linger once focus has moved on. It is notified as "dismiss".
  • ARIA: the Trigger has 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.

Styling and animation

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" }}>

Suspense

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>

Browser notes

  • experimental_focusgroup takes the native path in Chromium only; other engines keep the JS arrow keys (identical behaviour).
  • Safari: a nested popover with position-try-fallbacks can hang (WebKit bug 302008). If a submenu hits it, give its Popup side={null} and place it in CSS.
  • Context menus and menubars are not part of Menu.