Overlay

Menu

A menu of commands, from a click or a right-click. One bar slides behind the active row rather than a background being painted on each row in turn.

Usage

import { Menu } from '@carabine/ui/menu';
nothing yet
<Menu items={commands} onSelect={run} label="Actions">
  Actions
</Menu>

One bar, and it slides. Hover and keyboard focus are the same question — this is the one you are on — and answering it twice leaves a menu pointing at two rows.

Items are data

There is no Menu.Item. A list of commands in a real application is an array that gets built, filtered, reordered and permission-checked, so that is what is passed. Separators and headings are entries in the same array, which means a filter that removes the last item of a group removes its heading too, for free.

const commands = [
  { id: 'rename', label: 'Rename', icon: <Pencil />, shortcut: 'F2' },
  { id: 'copy', label: 'Copy link', icon: <Copy />, shortcut: '⌘C' },
  { id: 'sep', separator: true },
  { id: 'delete', label: 'Delete', icon: <Trash2 />, danger: true },
].filter((item) => can(item.id));

Examples

From a right-click

trigger="context" anchors the panel to the pointer rather than to the box — which is the only difference between the two modes, and the reason they are one component.

<Menu trigger="context" items={commands} label="Canvas actions">
  <Canvas />
</Menu>

Customization

<Menu
  items={commands}
  placement="right"
  align="start"
  width={260}
  padding={6}     // around the rows
  radius={14}     // rows take this minus the padding
  gap={6}
  offset={12}
  zIndex={65}     // above a dialog's 60, below a tooltip's 70
  theme="light"
/>

The rows' radius is the panel's minus the padding, not a second number. Two rounded things nested need that relationship or the corners run at each other.

Styling reference

The panel is a core — a single layer with the text on it — so it takes the heavier glass weight. The highlight behind the active row is a tint: no hairline, no shadow, and letting the surface show through is the entire point of it.

The panel itself is a tween; only the bar is a spring. A panel that merely opens is not interruptible, and a spring there would be movement for its own sake.

API reference

PropType
classNamestringAdded to the panel’s own classes, so yours wins
styleCSSPropertiesMerged after the component’s own inline styles, so yours wins

Both land on the panel. The trigger is already your element — you style it where you write it — and the panel is the part that portals away from your markup and is otherwise out of reach. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.

PropTypeDefault
childrenReactNodeThe trigger
itemsMenuItem[][]
onSelect(item, index) => void
trigger'click' | 'context''click'Off the box, or off the pointer
openbooleanControlled
defaultOpenbooleanfalse
onOpenChange(open: boolean) => void
placementSide'bottom'
alignAlign'start'
gapnumber6Between the anchor and the panel
offsetnumber12Smallest distance kept from the viewport’s edges
widthnumber220
paddingnumber6Around the rows
radiusnumber14Rows take this minus the padding
zIndexnumber65
containerHTMLElement | nulldocument.body
stiffnessnumber420The bar behind the active row
dampingnumber38The panel itself is a tween, not a spring
openScalenumber0.94
labelstring'Menu'Accessible name of the menu
theme'dark' | 'light''dark'

MenuItem carries id, label, description, icon, shortcut, href, external, disabled, danger, separator and heading. A row with an href renders as a link, so middle-click and open-in-new-tab work the way they do everywhere else.

Keyboard

Key
↑ ↓Walks the rows, wrapping, skipping disabled ones and separators
Home / EndFirst and last
A–ZTypeahead on the labels
EnterRuns the row
EscCloses, and focus returns to the trigger