Skip to content
Beta · ExperimentalReact 19No peer dependencies

Menu

A menu of commands with arrow keys, typeahead, checkbox and radio items and submenus, following the WAI-ARIA menu button pattern.

Beta components are subject to change and may break your code. Use them at your own risk, and share feedback through the bug tracker.

On this page

Installation

bash
npx gbs-add-block@latest -a Menu -beta

The block copies the menu folder into your project, along with the small shared folder that every component imports. You own the code and can change it freely. There are no peer dependencies other than React.

Requirements

  • React 19 and @types/react 19
  • TypeScript target ES2022 or newer, with "jsx": "react-jsx"
  • Browsers from 2024 or newer (the styles use light-dark() and the Popover API)

Import the stylesheet once, for example in your global CSS:

css
@import "../components/menu/styles.css";

or in your root layout / entry file:

ts
import "@/components/menu/styles.css";

The Menu holds commands: the actions on a row, the options behind a “…” button, a list of things to export. It opens in the browser's top layer, so it is never clipped by a toolbar or a scrolling panel, and the keyboard works the way the WAI-ARIA menu button pattern says it should.

Set the --gbs-* variables on :root to theme every component at once, the grid included. Each component's own variables fall back to them, and then to the built-in palette, so components look identical out of the box.

Default

Live preview

Quick Start

tsx
import {
  Menu, MenuItem, MenuSeparator, MenuSub,
  MenuCheckboxItem, MenuRadioGroup, MenuRadioItem, MenuGroup,
} from "@/components/menu";
import "@/components/menu/styles.css";
 
<Menu trigger={<Button variant="outline">Actions</Button>} label="Row actions">
  <MenuItem icon={<EditIcon />} shortcut="⌘E" onSelect={edit}>Edit</MenuItem>
  <MenuItem onSelect={duplicate}>Duplicate</MenuItem>
  <MenuItem disabled>Archive</MenuItem>
 
  <MenuSub label="Export">
    <MenuItem onSelect={() => download("csv")}>CSV</MenuItem>
    <MenuItem onSelect={() => download("pdf")}>PDF</MenuItem>
  </MenuSub>
 
  <MenuSeparator />
 
  <MenuCheckboxItem checked={compact} onCheckedChange={setCompact}>
    Compact rows
  </MenuCheckboxItem>
 
  <MenuRadioGroup value={sort} onValueChange={setSort} label="Sort by">
    <MenuRadioItem value="recent">Most recent</MenuRadioItem>
    <MenuRadioItem value="name">Name</MenuRadioItem>
  </MenuRadioGroup>
 
  <MenuSeparator />
 
  <MenuItem destructive onSelect={remove}>Delete</MenuItem>
</Menu>

trigger is cloned with aria-haspopup, aria-expanded and the handlers it needs, so pass a single element — a Button from this library, or your own.

Props Table

PropTypeDefaultDescription
triggerReactElementThe element that opens it. Cloned with the ARIA wiring.
childrenReactNodeItems, groups and separators.
openbooleanControlled open state.
defaultOpenbooleanfalseUncontrolled initial state.
onOpenChange(open, reason?) => voidreason is escape, outside, trigger, select or api.
labelstring"Menu"Names the menu for screen readers.
side"top" | "bottom" | "left" | "right"bottomSide to open on, before flipping for space.
align"start" | "center" | "end"startWhich edge to line up with.
gapnumber4Distance from the trigger.
closeOnSelectbooleantrueWhether choosing an item closes the menu.
id, className, classNames, style, localeTextSlots below.
refRef<MenuHandle>{ open, close, getElement }.

Items

ComponentPropsNotes
MenuItemonSelect, icon, shortcut, disabled, destructive, closeOnSelectCloses the menu by default.
MenuCheckboxItemchecked, onCheckedChange, plus the aboveStays open by default, so several can be toggled.
MenuRadioGroupvalue, onValueChange, labelWraps MenuRadioItems.
MenuRadioItemvalue, plus the item propsStays open by default.
MenuGrouplabelA titled section. The title is never focused.
MenuSeparatorA rule between sections.
MenuSublabel, icon, disabledA nested menu.

shortcut only draws the hint. Binding the key is yours, because only your page knows what else is listening.

Keyboard

KeyDoes
ArrowDown / ArrowUp on the triggerOpens, starting at the first or last item.
ArrowDown / ArrowUpMoves through items, wrapping at the ends.
Home / EndFirst / last item.
LettersTypeahead. Repeating a letter cycles through items starting with it.
Enter / SpaceChooses the focused item.
ArrowRightOpens a submenu and focuses its first item.
ArrowLeftCloses a submenu, back to its trigger. Mirrored in RTL.
EscapeCloses and returns focus to the trigger.
TabCloses and carries on through the page.

Disabled items keep their place and stay focusable, as the pattern prefers: someone navigating by keyboard can find out an action exists but is unavailable, rather than have it silently disappear. Arrow keys skip past them, so they never trap the sequence.

tsx
<MenuSub label="Export">
  <MenuItem onSelect={() => download("csv")}>CSV</MenuItem>
  <MenuSub label="More formats">
    <MenuItem onSelect={() => download("json")}>JSON</MenuItem>
  </MenuSub>
</MenuSub>

A submenu opens on hover after a short pause, on Enter, or on ArrowRight, and closes when the pointer leaves both it and its trigger. Submenus nest as deeply as you like, though two levels is usually the point at which a dialog would serve people better.

A submenu is a manual popover, not an automatic one. Opening an automatic popover closes every other one, which would take the parent menu down with it the moment a submenu appeared.

Styling and Theming

All rules are in the CSS components layer, so utility classes passed through className / classNames override them.

tsx
<Menu
  trigger={<Button>Actions</Button>}
  className="min-w-56"
  classNames={{ item: "text-[13px]", separator: "my-1" }}
>

CSS variables

VariableUsed for
--mn-bg, --mn-fgPanel background and text.
--mn-mutedIcons, shortcuts, group titles, disabled items.
--mn-borderPanel border and separators.
--mn-hoverHovered and focused items.
--mn-accentThe check and the radio dot.
--mn-dangerdestructive items.
--mn-shadow, --mn-radius, --mn-font-sizePanel shape.
--mn-item-radiusCorner radius of each item.
--mn-min-widthMinimum panel width (default 180px).

Override them on .mn-root, on :root, or through style. Each variable falls back to the shared --gbs-* of the same name, then to the DataGrid's --dg-* when that stylesheet is loaded, and finally to the built-in palette.

Data attributes

ElementAttributes
Root (.mn-root)data-side — the side it settled on after flipping.
Item (.mn-item)data-disabled, data-destructive
Submenu triggeraria-expanded

Locale Text

tsx
<Menu localeText={{ label: "Acties", submenu: (name) => `${name} submenu` }} />

Headless Use

core/navigation.ts has no React in it, so the same rules can drive your own markup:

ts
import { nextIndex, edgeIndex, typeaheadIndex, typeaheadBuffer } from "@/components/menu";
 
const items = [{ text: "Save" }, { text: "Save as", disabled: true }, { text: "Print" }];
nextIndex(items, 0, 1);            // 2 — skips the disabled item
edgeIndex(items, "last");          // 2
typeaheadIndex(items, "p", 0);     // 2

Next.js

The file is already marked "use client". Import the stylesheet in your root layout, and render the Menu inside a client component — it is interactive by definition.

Notes

  • The menu renders in the top layer through the native Popover API, so it escapes overflow: hidden without a portal.
  • Placement comes from shared/core/position.ts, which is unit-tested on its own.
  • Nothing is measured until the menu opens, so a page with a hundred row menus pays for none of them.