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';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.
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
| Prop | Type | |
|---|---|---|
| className | string | Added to the panel’s own classes, so yours wins |
| style | CSSProperties | Merged 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.
| Prop | Type | Default | |
|---|---|---|---|
| children | ReactNode | — | The trigger |
| items | MenuItem[] | [] | |
| onSelect | (item, index) => void | — | |
| trigger | 'click' | 'context' | 'click' | Off the box, or off the pointer |
| open | boolean | — | Controlled |
| defaultOpen | boolean | false | |
| onOpenChange | (open: boolean) => void | — | |
| placement | Side | 'bottom' | |
| align | Align | 'start' | |
| gap | number | 6 | Between the anchor and the panel |
| offset | number | 12 | Smallest distance kept from the viewport’s edges |
| width | number | 220 | |
| padding | number | 6 | Around the rows |
| radius | number | 14 | Rows take this minus the padding |
| zIndex | number | 65 | |
| container | HTMLElement | null | document.body | |
| stiffness | number | 420 | The bar behind the active row |
| damping | number | 38 | The panel itself is a tween, not a spring |
| openScale | number | 0.94 | |
| label | string | '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 / End | First and last |
| A–Z | Typeahead on the labels |
| Enter | Runs the row |
| Esc | Closes, and focus returns to the trigger |