Dropdown Menu
A focus-managed menu anchored beneath a trigger button. Root owns the open state; Trigger toggles it and announces aria-haspopup / aria-expanded to assistive tech; Content renders the panel, handles outside-click + Escape dismissal, traps roving keyboard focus, and accepts either compound children (Item, Separator, Label) or a flat items array for the common case.
Installation
npx shadcn@latest add https://craftbits.dev/r/dropdown-menu.jsonUsage
Compound form — full control over what goes between rows:
import { DropdownMenu } from "@craft-bits/core";
<DropdownMenu.Root>
<DropdownMenu.Trigger>Options</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>Account</DropdownMenu.Label>
<DropdownMenu.Item onSelect={() => goProfile()}>
Profile
<DropdownMenu.Shortcut>⌘P</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => goSettings()}>Settings</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onSelect={() => signOut()}>
Sign out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>Declarative form — pass an items array when the menu is a flat list:
<DropdownMenu.Root>
<DropdownMenu.Trigger>Options</DropdownMenu.Trigger>
<DropdownMenu.Content
items={[
{ id: "rename", label: "Rename", onSelect: () => rename(), hint: "⌘R" },
{ id: "duplicate", label: "Duplicate", onSelect: () => duplicate() },
{
id: "delete",
label: "Delete",
variant: "destructive",
separatorBefore: true,
onSelect: () => del(),
},
]}
/>
</DropdownMenu.Root>Controlled — drive open state from your own store:
const [open, setOpen] = useState(false);
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
{/* … */}
</DropdownMenu.Root>Understanding the component
- Compound parts.
Rootis the state owner + positioning anchor (arelative<span>wrapping the trigger and content).Triggeris the toggle button — getsaria-haspopup="menu",aria-expanded, andaria-controls.Contentis the panel; it carriesrole="menu"and traps roving focus across itsItemchildren.Itemcarriesrole="menuitem".Separatoris a divider,Labelis a group heading,Shortcutis the⌘K-style right-aligned hint. - Items[] vs. children. For flat menus, pass
itemsonContent— adjacent items sharing agroupvalue cluster under one auto-emittedLabel, andseparatorBefore: trueinserts a divider. For richer menus, compose the children directly. - Controlled & uncontrolled. Pass
open+onOpenChangefor controlled, ordefaultOpento letRootown the state. Same Radix convention asDialog,Popover,Tabs. - Keyboard.
ArrowDown/ArrowUpon the trigger opens the menu; once open, the same keys cycle through enabled items,Home/Endjump to the ends,Enter/Spaceselect,Escapecloses, andTabcloses (Radix convention). - Dismissal. Outside-click closes (
pointerdownlistener in the capture phase to avoid the "click leaked through" bug). Escape closes. Selecting an item closes — unlessonSelectcallsevent.preventDefault(), useful for checkbox-like rows that toggle without dismissing. - Focus management. On open, focus moves to the first enabled item on the next animation frame. On close, focus restores to the trigger.
- Motion. The panel scale-pops with
SPRINGS.snap.<AnimatePresence initial={false}>skips animation on the first render so adefaultOpenmount doesn't flash. - Anchoring.
side="bottom"(default) anchors below;"top"anchors above.align="start"left-aligns;"end"right-aligns — useful for toolbar triggers near the viewport's right edge.
Props
DropdownMenu.Root
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. Pair with onOpenChange. |
defaultOpen | boolean | false | Uncontrolled initial open state. |
onOpenChange | (open: boolean) => void | — | Fires when the menu opens or closes. |
DropdownMenu.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Merged onto the rendered <button>. |
DropdownMenu.Content
| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly DropdownMenuItemDescriptor[] | — | Flat-list shortcut. When provided, children are not required. |
side | "top" | "bottom" | "bottom" | Vertical anchor relative to the trigger. |
align | "start" | "end" | "start" | Horizontal alignment relative to the trigger. |
sideOffset | number | 6 | Pixel gap between the trigger and the panel. |
className | string | — | Merged onto the rendered panel. |
DropdownMenu.Item
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Dims the row and skips it in keyboard navigation. |
variant | "default" | "destructive" | "default" | Visual tone — destructive swaps to danger colours. |
onSelect | (event) => void | — | Fires on click or Enter. Closes the menu unless event.preventDefault(). |
className | string | — | Merged onto the rendered row. |
Accessibility
- The trigger renders as a native
<button>carryingaria-haspopup="menu",aria-expanded, andaria-controlsso assistive tech announces the relationship to the open menu. - The panel renders with
role="menu"and is labelled by the trigger viaaria-labelledby. Each row carriesrole="menuitem"and atabIndexof0(or-1when disabled). - Roving keyboard focus is managed inside the panel.
ArrowDown/ArrowUpcycle through enabled items,Home/Endjump to the ends,Enter/Spaceselect,Escapecloses, andTabcloses (matching the Radix dismissal convention). - On open, focus moves to the first enabled item on the next animation frame. On close, focus restores to the trigger so keyboard users return to where they were.
- Disabled rows carry
aria-disabled="true"anddata-disabled="true"; they're skipped by the roving focus and the click handler short-circuits before firingonSelect. - Outside-click and Escape dismiss the menu; selecting an item also closes unless the
onSelecthandler callsevent.preventDefault()(Radix convention for checkbox-like rows). - The
destructivevariant only changes colour, never semantics — pair with clear copy ("Delete forever") to convey danger.
Credits
- Extracted from:
algoflashcards(src/platform/ui/dropdown-menu.tsx). The original wrapped theradix-uiumbrella'sDropdownMenuPrimitiveplus a@phosphor-icons/reactdep for the caret + check glyphs; craft-bits drops the umbrella dependency and the icon-pack coupling, ships a self-contained focus-managed menu, and adds the declarativeitems[]shortcut onContent. - Inspired by:
@radix-ui/react-dropdown-menu— the canonical headless dropdown.