Dropdown Menu
WAI-ARIA's menu button on the native Popover API, with icons, shortcuts, checkbox and radio rows, submenus, typeahead and a highlight that glides between rows. Plus a right-click ContextMenu.
Installation
Add the registry to components.json, once
{
"registries": {
"@rabtx": "http://localhost:3002/r/{name}.json"
}
}Add the component
npx shadcn@latest add @rabtx/dropdown-menuOr, without the namespace, by URL:
npx shadcn@latest add http://localhost:3002/r/dropdown-menu.jsonThe files land in your components/ui and are styled by your own theme. Add --overwrite to replace a shadcn component with the same name.
The menu is a popover: installing it also installs popover for its placement and motion.
Usage
Installed from the registry, import from @/components/ui/dropdown-menu; inside this monorepo,
from @rabtx/ui/dropdown-menu.
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@rabtx/ui/dropdown-menu";
export function RowActions() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Actions">
<HugeiconsIcon icon={MoreHorizontalIcon} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem icon={<HugeiconsIcon icon={Copy01Icon} />} shortcut="⌘D">
Duplicate
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={remove}>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}The parts and data-slot names are shadcn's. The content is a role="menu" [popover] and the
trigger its native invoker, so the top layer, a press outside, Escape and focus going back to the
trigger come from the browser. The component adds the menu keyboard: roving focus over the rows,
arrows, Home and End, typeahead and submenus.
Icons, shortcuts and a destructive action
A row takes an icon, a shortcut hint, variant="destructive" and disabled. Disabled rows are
skipped by the keys and cannot be chosen.
Checkboxes
DropdownMenuCheckboxItem is a menuitemcheckbox; its tick draws itself in on the check spring.
Choosing a row closes the menu; call event.preventDefault() in onSelect to keep it open while
toggling several.
Radio group
DropdownMenuRadioGroup holds the value; each DropdownMenuRadioItem is a menuitemradio whose
dot pops in on the swap spring. A DropdownMenuLabel inside a group (or radio group) names it.
Context menu
ContextMenu opens the same menu at the pointer on a right-click over ContextMenuTrigger, or
under the focused element with the menu key or Shift+F10 (landing on the first row). It is anchored
to an invisible point where the press was, unfolds from it, and flips near the edges. The rows are
the dropdown menu's, exported again as ContextMenuItem, ContextMenuCheckboxItem and so on.
API
DropdownMenu (and ContextMenu) hold the state:
Prop
Type
DropdownMenuContent (and DropdownMenuSubContent, ContextMenuContent):
Prop
Type
DropdownMenuItem:
Prop
Type
DropdownMenuCheckboxItem adds checked, defaultChecked and onCheckedChange;
DropdownMenuRadioGroup takes value, defaultValue and onValueChange, and each
DropdownMenuRadioItem a value. DropdownMenuSub wraps a DropdownMenuSubTrigger and its
DropdownMenuSubContent; one submenu per menu is open at a time. DropdownMenuLabel,
DropdownMenuGroup, DropdownMenuSeparator and DropdownMenuShortcut are divs and a span.
Theming
| Part | Tokens |
|---|---|
| Panel | the popover's: --popover, --popover-foreground, the surface edge tokens, --radius-box |
| Rows | text --popover-foreground, icons, labels and shortcuts --muted-foreground, corners --radius-item |
| Highlight | --accent (text --accent-foreground); destructive rows --destructive and a 12% tint of it |
| Separator | --border |
| Motion | --spring-unfold (panel), --spring-check (tick), --spring-swap (dot), --motion-ease |
How it works
| Effect | CSS |
|---|---|
| Placement, flip, unfold | the popover's: anchor positioning, try fallbacks and a reveal from the anchor side |
| Highlight | the active row (focused, or a submenu's open row) takes anchor-name; one highlight, the panel's last child, is placed on it with anchor() and its top and bottom glide in 160ms. anchor-scope keeps each menu's rows to itself |
| Tick | stroke-dashoffset from 1 to 0 over pathLength="1" on the check spring |
| Dot | scale from 0 on the swap spring |
| Submenu | a nested [popover] anchored to its row, side="right", flipping left near the edge |
Without anchor scoping, the active row fills itself instead of the gliding highlight.
Accessibility
- The trigger has
aria-haspopup="menu",aria-expandedandaria-controls, and names the menu (aria-labelledby). - Rows are
menuitem,menuitemcheckbox(witharia-checked) andmenuitemradio; disabled rows havearia-disabled; groups arerole="group"named by their label; separators arerole="separator". - Enter, Space and ArrowDown open the menu on the first row, ArrowUp on the last; a click opens it with focus on the menu. Arrows move and wrap, Home and End jump, letters jump by typeahead, Enter and Space choose. ArrowRight, Enter or Space open a submenu on its first row, ArrowLeft and Escape close it and go back to its row. Escape closes the menu and Tab closes it and moves on; focus goes back to the trigger.
Popover
A non-modal panel on the native Popover API, anchored to its trigger with CSS anchor positioning. It flips to fit and unfolds from the side it opened on, in CSS.
Toast
Stacked toasts in a top-layer region, with an imperative toast() API, actions, promises, swipe to dismiss and live announcements. Every animation is CSS.