Menu
StableA menu button with actions, checkbox and radio items, group labels, shortcut hints and type-ahead, in a popover beside its trigger.
You render the trigger: a function gets the props to spread onto any button (a ref, handlers and the ARIA attributes). Items are plain data: actions with an icon, a hint or a shortcut, checkbox and radio items with a tick, separators and group labels. Choosing an item closes the menu, runs onSelect and puts focus back on the trigger.
Row actions
import { useRef } from 'react'import { useHotkeys } from '@rchursin/product/lib/hotkeys'import { IconButton, Menu, useToast } from '@rchursin/product/ui'
/** * A row's actions behind "more". The shortcut hints are real here: with focus on the row, * P, Shift+D, R and E work without opening the menu (ignoreOverlays leaves keys pressed inside * the open menu to its type-ahead). A disabled item says why in its hint. */export default function MenuActions() { const toast = useToast() const row = useRef<HTMLDivElement>(null)
const pin = () => toast({ title: 'Run pinned to the top', icon: 'pin' }) const duplicate = () => toast({ title: 'Run duplicated · run 1483', icon: 'copy' }) const rerun = () => toast({ title: 'Run 1482 queued again', icon: 'refresh' }) const archive = () => toast({ title: 'Run archived', icon: 'archive', action: { label: 'Undo', onClick: () => toast({ title: 'Run restored', icon: 'undo' }) } })
useHotkeys({ p: pin, 'shift+d': duplicate, r: rerun, e: archive }, { scope: row, ignoreOverlays: true })
Usage
import { Menu } from '@rchursin/product/ui'<Menu label="Run actions" items={[ { label: 'Duplicate', icon: 'copy', onSelect: duplicate }, { type: 'separator' }, { label: 'Archive', icon: 'archive', onSelect: archive },]}> {(trigger) => <IconButton {...trigger} icon="more-horizontal" label="Run actions" />}</Menu>Examples
Checkbox and radio items
Columns are checkboxes, the sort is a radio group. Open it and type T to jump to Tokens, or S for Slowest first.
import { useState } from 'react'import { Button, Menu } from '@rchursin/product/ui'
type Column = 'cost' | 'tokens' | 'latency'type Sort = 'newest' | 'cost' | 'latency'
const runs = [ { id: 1482, when: 3, cost: 0.38, tokens: 41_200, latency: 2.4 }, { id: 1481, when: 2, cost: 1.12, tokens: 118_900, latency: 6.1 }, { id: 1480, when: 1, cost: 0.07, tokens: 9_800, latency: 1.2 },]
const columns: { key: Column; label: string; show: (r: (typeof runs)[number]) => string }[] = [ { key: 'cost', label: 'Cost', show: (r) => `$${r.cost.toFixed(2)}` }, { key: 'tokens', label: 'Tokens', show: (r) => r.tokens.toLocaleString('en-US') }, { key: 'latency', label: 'Latency', show: (r) => `${r.latency.toFixed(1)} s` },]
/** * Checkbox items toggle columns; radio items pick one sort. Group labels and a separator keepAPI
Menu props
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | – | The menu’s accessible name, set on role="menu". Usually the trigger’s words. |
itemsrequired | MenuItem[] | – | What to show, in order. See MenuItem below. |
childrenrequired | (trigger: MenuTriggerProps) => ReactNode | – | Renders the trigger. Spread trigger onto a Button or IconButton. |
placement | Placement | 'bottom-end' | Where the panel opens; it flips above when there is no room below. |
className | string | – | Classes on the panel (it starts at min-w-48 with p-1). |
MenuItem
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'item' | 'checkbox' | 'radio' | 'separator' | 'label' | 'item' | separator draws a hairline; label is a small mono heading over the items below it. |
label | string | – | The words, and what type-ahead matches. Required on everything but a separator. |
onSelect | () => void | – | Runs when the item is chosen. Required on items, checkboxes and radios. |
icon | IconName | – | Drawn before the label (after the tick on checkbox and radio items). |
shortcut | string | – | A key hint on the right, like "shift+d". Display only: bind the keys with useHotkeys. |
hint | string | – | A second line under the label, plain items only. Say why an item is disabled here. |
disabled | boolean | – | Drawn at 45% opacity and can’t be chosen. The arrow keys and type-ahead still reach it, so its hint is read. |
checked | boolean | – | Checkbox and radio items: draws the tick and sets aria-checked. |
MenuTriggerProps
| Prop | Type | Default | Description |
|---|---|---|---|
ref | (el: HTMLButtonElement | null) => void | – | Registers the trigger as the anchor. Button and IconButton forward it. |
onClick | () => void | – | Opens or closes the menu. |
onKeyDown | (e: KeyboardEvent) => void | – | Enter, Space and ↓ open on the first item; ↑ opens on the last. |
'aria-haspopup' | 'menu' | – | Tells assistive tech the button opens a menu. |
'aria-expanded' | boolean | – | true while the menu is open. |
'aria-controls' | string | – | The id of the role="menu" list. |
id | string | – | A generated id for the trigger. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| EnterSpaceDown arrow | On the trigger: opens the menu on the first item. |
| Up arrow | On the trigger: opens the menu on the last item. |
| Down arrowUp arrow | In the menu: next or previous item, wrapping round. Disabled items get focus too (so they are read) but can’t be chosen. |
| HomeEnd | First or last item. |
| A | Any letter jumps to the next item that starts with it. Type a few letters quickly (within 0.6 seconds) to match more of a label. |
| EnterSpace | In the menu: chooses the item. The menu closes, onSelect runs and focus goes back to the trigger. |
| Esc | Closes without choosing; focus goes back to the trigger. |
| Tab | Closes the menu; focus moves on to the next control on the page. |
- It follows the APG menu button: the trigger gets aria-haspopup="menu", aria-expanded and aria-controls; the list is role="menu", named by label.
- Items are role="menuitem", "menuitemcheckbox" or "menuitemradio"; checkbox and radio items carry aria-checked. Disabled items carry aria-disabled="true" and stay focusable, as the APG asks, so the arrow keys reach them and their hint (“No other projects yet”) is read; Enter, Space and clicks do nothing.
- Focus moves onto each item as you arrow, so screen readers read the item you are on.
- Checked items show a tick, a shape rather than a colour. Shortcut hints are hidden from screen readers, and group labels are not announced as group names.
- A click outside also closes it. The panel is a Popover with role none, so it is never clipped.
Guidelines
Do
- Start item labels with a verb: “Duplicate”, “Archive”.
- Put destructive items last, after a separator.
- Keep a disabled item visible when people would look for it, with a hint that says why.
- Bind the shortcuts you show, or don’t show them.
Don’t
- Don’t use a menu to move between pages; use links.
- Don’t put more than about ten items in one menu; group them or use the command palette.
- Don’t put the only way to do something behind a menu with no shortcut or button elsewhere.
In use
Related
- PopoverA panel beside its trigger, on the native popover API: never clipped, closed by Esc or a click outside, and it hands focus back.
- Icon buttonA square button with just an icon. The label is required: it is the accessible name and the tooltip.
- useHotkeysSingle-key and modifier shortcuts, Linear style, that wait while you type and can be scoped to one part of the page.
- Command paletteOne search field over every page and action, opened with ⌘K, grouped and ranked, run with Enter.
- RemindersReminders grouped from Overdue to Done, checked off, snoozed or deleted with Undo, plus a compact widget for a dashboard.
Source
The component’s own files, exactly as they run on this site.