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.json

Usage

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

  1. Compound parts. Root is the state owner + positioning anchor (a relative <span> wrapping the trigger and content). Trigger is the toggle button — gets aria-haspopup="menu", aria-expanded, and aria-controls. Content is the panel; it carries role="menu" and traps roving focus across its Item children. Item carries role="menuitem". Separator is a divider, Label is a group heading, Shortcut is the ⌘K-style right-aligned hint.
  2. Items[] vs. children. For flat menus, pass items on Content — adjacent items sharing a group value cluster under one auto-emitted Label, and separatorBefore: true inserts a divider. For richer menus, compose the children directly.
  3. Controlled & uncontrolled. Pass open + onOpenChange for controlled, or defaultOpen to let Root own the state. Same Radix convention as Dialog, Popover, Tabs.
  4. Keyboard. ArrowDown / ArrowUp on the trigger opens the menu; once open, the same keys cycle through enabled items, Home / End jump to the ends, Enter / Space select, Escape closes, and Tab closes (Radix convention).
  5. Dismissal. Outside-click closes (pointerdown listener in the capture phase to avoid the "click leaked through" bug). Escape closes. Selecting an item closes — unless onSelect calls event.preventDefault(), useful for checkbox-like rows that toggle without dismissing.
  6. Focus management. On open, focus moves to the first enabled item on the next animation frame. On close, focus restores to the trigger.
  7. Motion. The panel scale-pops with SPRINGS.snap. <AnimatePresence initial={false}> skips animation on the first render so a defaultOpen mount doesn't flash.
  8. 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

PropTypeDefaultDescription
openbooleanControlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseUncontrolled initial open state.
onOpenChange(open: boolean) => voidFires when the menu opens or closes.

DropdownMenu.Trigger

PropTypeDefaultDescription
classNamestringMerged onto the rendered <button>.

DropdownMenu.Content

PropTypeDefaultDescription
itemsreadonly 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.
sideOffsetnumber6Pixel gap between the trigger and the panel.
classNamestringMerged onto the rendered panel.

DropdownMenu.Item

PropTypeDefaultDescription
disabledbooleanfalseDims the row and skips it in keyboard navigation.
variant"default" | "destructive""default"Visual tone — destructive swaps to danger colours.
onSelect(event) => voidFires on click or Enter. Closes the menu unless event.preventDefault().
classNamestringMerged onto the rendered row.

Accessibility

  • The trigger renders as a native <button> carrying aria-haspopup="menu", aria-expanded, and aria-controls so assistive tech announces the relationship to the open menu.
  • The panel renders with role="menu" and is labelled by the trigger via aria-labelledby. Each row carries role="menuitem" and a tabIndex of 0 (or -1 when disabled).
  • Roving keyboard focus is managed inside the panel. ArrowDown / ArrowUp cycle through enabled items, Home / End jump to the ends, Enter / Space select, Escape closes, and Tab closes (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" and data-disabled="true"; they're skipped by the roving focus and the click handler short-circuits before firing onSelect.
  • Outside-click and Escape dismiss the menu; selecting an item also closes unless the onSelect handler calls event.preventDefault() (Radix convention for checkbox-like rows).
  • The destructive variant 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 the radix-ui umbrella's DropdownMenuPrimitive plus a @phosphor-icons/react dep 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 declarative items[] shortcut on Content.
  • Inspired by: @radix-ui/react-dropdown-menu — the canonical headless dropdown.