Dropdown menu
A list of actions or choices that opens from a button.
This is the product's one dropdown menu. It was added with shadcn add @reui/c-dropdown-menu-3, which is built on the Base UI Menu, and then set in Petavue colours: a white panel, --color-primary-50 behind the highlighted item, primary text, and a blue check on the selected item. ListDropdown, ModelModeMenu and Dropdown are all built on it, so a fix here reaches every menu.
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuDescription,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuShortcut,
} from "@/components/shadcn/dropdown-menu";For filters with search and a Reset, Cancel and Apply footer, use the Dropdown panel. It matches the Figma Dropdown component. This menu is more compact than Figma's: 36px items inside a padded panel, with the check on the right, where Figma has 46px items edge to edge with the checkbox or radio on the left.
Actions
Pass the product Button to the trigger with render, so the trigger looks and focuses like every other button. Give each item an onClick. The menu closes after an item is chosen.
import { CaretDown, CopySimple, FolderSimple, PencilSimple, Trash } from "@phosphor-icons/react";
import { Button } from "@/ui";
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="secondaryGhost" label="Page actions" icon={CaretDown} iconPosition="suffix" />}
/>
<DropdownMenuContent>
<DropdownMenuItem label="Rename" onClick={rename}>
<PencilSimple size={16} /> Rename
</DropdownMenuItem>
<DropdownMenuItem label="Duplicate" onClick={duplicate}>
<CopySimple size={16} /> Duplicate
</DropdownMenuItem>
<DropdownMenuItem label="Move to folder" onClick={move}>
<FolderSimple size={16} /> Move to folder
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem label="Delete page" variant="destructive" onClick={remove}>
<Trash size={16} /> Delete page
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>Items use onClick, not onSelect. Base UI has no onSelect on items, so code copied from Radix-based shadcn examples silently does nothing.
When an item starts with an icon, also pass label with its plain text. Without it, typing a letter to jump to that item does not find it.
Put a destructive item last, after a separator, with variant="destructive". It is drawn in --color-error-500 (falling back to #f93d3d) with a pale red highlight. Confirm the action in a dialog if it cannot be undone.
Choosing one value
For a filter or a setting that shows its current value on the trigger, list the options as items and mark the chosen one with a bold 16px Check in --color-primary-500. This is how the Recommendations filters and the composer menus work. Put the value on the trigger and say what it is in aria-label.
import { CaretDown, Check } from "@phosphor-icons/react";
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="secondary" label={current.label} icon={CaretDown} iconPosition="suffix" aria-label={`Date range: ${current.label}`} />}
/>
<DropdownMenuContent className="min-w-[200px]">
{RANGES.map((r) => (
<DropdownMenuItem key={r.id} aria-current={r.id === range || undefined} onClick={() => setRange(r.id)}>
<span className="flex-1">{r.label}</span>
{r.id === range && <Check size={16} weight="bold" className="text-[var(--color-primary-500)]" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>A plain item with a check does not tell a screen reader which option is chosen. If that matters, use a radio group instead (see below), which announces the checked option.
With a description
DropdownMenuDescription is a Petavue addition. It puts a second line in 12px --color-grey-500 under an item's label. Use it when the label alone does not say what will happen. Add items-start to the item so the icon lines up with the first line.
<DropdownMenuContent align="end" sideOffset={6} aria-label="Export">
{options.map((o) => (
<DropdownMenuItem key={o.id} className="items-start" onClick={o.run}>
<o.icon size={16} className="mt-0.5" />
<span className="flex min-w-0 flex-1 flex-col">
<span>{o.label}</span>
<DropdownMenuDescription>{o.description}</DropdownMenuDescription>
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>Groups, labels and shortcuts
Group related items and name the group with DropdownMenuLabel. A label must sit inside a DropdownMenuGroup. Separate groups with DropdownMenuSeparator.
import { DotsThree } from "@phosphor-icons/react";
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="secondaryGhost" icon={DotsThree} iconWeight="bold" aria-label="Campaign actions" />}
/>
<DropdownMenuContent className="min-w-[220px]">
<DropdownMenuGroup>
<DropdownMenuLabel>Campaign</DropdownMenuLabel>
<DropdownMenuItem>Open in Google Ads <DropdownMenuShortcut>⌘O</DropdownMenuShortcut></DropdownMenuItem>
<DropdownMenuItem>Duplicate <DropdownMenuShortcut>⌘D</DropdownMenuShortcut></DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Reporting</DropdownMenuLabel>
<DropdownMenuItem>Add to dashboard</DropdownMenuItem>
<DropdownMenuItem>Schedule a weekly summary</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>DropdownMenuShortcut only shows the text. It does not bind the key. Show a shortcut only when the page really handles it.
Checkbox items
Use checkbox items to switch several things on or off, such as table columns. The menu stays open after each click so people can change more than one. Checked items show the blue check on the right.
<DropdownMenuContent className="min-w-[220px]">
<DropdownMenuGroup>
<DropdownMenuLabel>Show in table</DropdownMenuLabel>
{COLUMNS.map((c) => (
<DropdownMenuCheckboxItem key={c} checked={shown.includes(c)} onCheckedChange={(on) => toggle(c, on)}>
{c}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>Radio items
Use a radio group to choose one value when you want the choice announced as checked. Radio items stay open after a click by default. Pass closeOnClick when choosing should close the menu, as most single-choice menus should.
<DropdownMenuRadioGroup value={attribution} onValueChange={setAttribution}>
<DropdownMenuRadioItem value="first" closeOnClick>First touch</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="last" closeOnClick>Last touch</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="linear" closeOnClick>Linear</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="w" closeOnClick>W-shaped</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>Sub menus
A sub menu opens to the right on hover or with the right arrow key. Keep to one level. If you need a second level, the choice probably belongs in a dialog.
<DropdownMenuContent className="min-w-[200px]">
<DropdownMenuItem onClick={downloadCsv}>Download as CSV</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Add to dashboard</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{dashboards.map((d) => (
<DropdownMenuItem key={d.id} onClick={() => addTo(d)}>{d.name}</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>Placement
The panel opens below the trigger, lined up with its left edge, 4px away. Use align="end" for triggers on the right of a header or row, so the panel stays on screen. Use side="top" for triggers at the bottom of the screen, such as a composer. If there is not enough room, the panel flips to the other side by itself.
<DropdownMenuContent align="start">…</DropdownMenuContent>
<DropdownMenuContent align="end">…</DropdownMenuContent>
<DropdownMenuContent side="top" sideOffset={8}>…</DropdownMenuContent>The panel is at least as wide as the trigger and at most 360px (or 92% of the screen). It grows to fit its longest item, so set min-w-[…] through className when a short list from a small trigger looks cramped. It scrolls when it is taller than the space available. Set a max-h-[…] for long lists.
States
<DropdownMenuItem disabled className="items-start">
<span className="flex flex-col">
<span>Publish to Recommendations</span>
<DropdownMenuDescription>Approve the latest run first</DropdownMenuDescription>
</span>
</DropdownMenuItem>
<DropdownMenuTrigger disabled render={<Button variant="secondary" label="Export" icon={CaretDown} iconPosition="suffix" />} />| State | Look |
|---|---|
| Default | 14px --color-text-primary on white, 8px by 10px padding, 6px corners. |
| Highlighted (hover or arrow keys) | --color-primary-50 background. |
| Checked | Bold 16px check in --color-primary-500, 10px from the right edge. |
| Disabled item | 50% opacity. Arrow keys still reach it, but it cannot be chosen. Say why in a description. |
| Disabled trigger | Set disabled on DropdownMenuTrigger. The Button shows its own disabled style. |
| Open sub menu trigger | Keeps the --color-primary-50 background while its sub menu is open. |
Props
DropdownMenu
| Prop | Type | Default | Notes |
|---|---|---|---|
open | boolean | Controlled open state. Leave it out unless you need to open or close the menu from code. | |
onOpenChange | (open, details) => void | ||
defaultOpen | boolean | false | |
modal | boolean | true | While open, the page does not scroll and clicks outside only close the menu. |
loopFocus | boolean | true | Arrow keys wrap from the last item to the first. |
disabled | boolean | false |
DropdownMenuTrigger
| Prop | Type | Default | Notes |
|---|---|---|---|
render | element | Pass <Button … />. Its props are merged with the trigger's. Children of the trigger become the Button's children. | |
disabled | boolean | false | |
openOnHover | boolean | false | Avoid. Menus in the product open on click. |
DropdownMenuContent
| Prop | Type | Default | Notes |
|---|---|---|---|
align | "start" | "center" | "end" | "start" | |
alignOffset | number | 0 | |
side | "bottom" | "top" | "left" | "right" | "bottom" | |
sideOffset | number | 4 | The product uses 4 or 6, and 8 above a composer. |
className | string | Width and height only, such as min-w-[240px] max-h-[320px]. | |
aria-label | string | The panel is labelled by its trigger already. Add this when the trigger text is a value rather than a name. |
It renders in a portal at z-index: 2000, with a 1px --color-grey-200 border, 8px corners, 6px padding and a 0 8px 24px rgba(31, 33, 46, 0.12) shadow.
DropdownMenuItem
| Prop | Type | Default | Notes |
|---|---|---|---|
onClick | (event) => void | Runs when the item is chosen by mouse, Enter or Space. | |
variant | "default" | "destructive" | "default" | |
disabled | boolean | false | |
label | string | The text used when someone types to jump to an item. Set it when the item starts with an icon. | |
closeOnClick | boolean | true | |
inset | boolean | Adds 32px left padding so text lines up with items that have an icon. Also on Label, CheckboxItem, RadioItem and SubTrigger. |
Other parts
| Part | Props | Notes |
|---|---|---|
DropdownMenuCheckboxItem | checked, onCheckedChange(checked), defaultChecked, disabled, closeOnClick (default false) | |
DropdownMenuRadioGroup | value, onValueChange(value), defaultValue | |
DropdownMenuRadioItem | value (required), disabled, closeOnClick (default false) | |
DropdownMenuSub | open, onOpenChange | Wraps a SubTrigger and SubContent. |
DropdownMenuSubTrigger | disabled, openOnHover (default true), delay (default 100) | Adds a 14px caret on the right. |
DropdownMenuSubContent | Same as Content | Defaults to side="right", alignOffset={-3}, sideOffset={0}, min width 96px. |
DropdownMenuLabel | className, inset | 12px medium --color-grey-500. Must be inside a Group. |
DropdownMenuDescription | className | 12px, 18px line height, --color-grey-500. A plain <span>. |
DropdownMenuShortcut | className | 12px --color-grey-500, pushed to the right. Text only. |
DropdownMenuSeparator | className | 1px --color-grey-100, full width of the panel. |
DropdownMenuGroup | Groups items for screen readers. |
Accessibility
- The trigger gets
aria-haspopup="menu"andaria-expanded. The panel hasrole="menu"and is labelled by the trigger. - Enter, Space or the down arrow on the trigger opens the menu and highlights the first item. The up arrow opens it on the last item.
- Up and down arrows move through items and wrap around. Home and End jump to the first and last. Typing a letter jumps to the next item that starts with it.
- Enter or Space chooses the highlighted item. The menu closes and focus returns to the trigger.
- Escape closes the menu and returns focus to the trigger. Inside a sub menu, Escape or the left arrow closes only the sub menu.
- The right arrow opens a sub menu and moves into it.
- Tab closes the menu and moves focus on to the next control on the page.
- Checkbox and radio items are announced as
menuitemcheckboxandmenuitemradiowitharia-checked. Plain items with a drawn check are not, so usearia-currenton them at least. - An icon-only trigger needs
aria-label, as in the campaign actions example.
Do and don't
- Do use the product Button as the trigger, through
render. - Do write items as a verb and an object: "Duplicate page", "Add to dashboard".
- Do put destructive items last, after a separator.
- Do use
align="end"for triggers near the right edge. - Don't build a menu from a
<div>and a click handler, or bring in another menu library. Use this one. - Don't nest more than one level of sub menus.
- Don't restyle items with colour classes. If a menu needs a new look, change it here so every menu gets it.