Design system

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>

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" />} />
StateLook
Default14px --color-text-primary on white, 8px by 10px padding, 6px corners.
Highlighted (hover or arrow keys)--color-primary-50 background.
CheckedBold 16px check in --color-primary-500, 10px from the right edge.
Disabled item50% opacity. Arrow keys still reach it, but it cannot be chosen. Say why in a description.
Disabled triggerSet disabled on DropdownMenuTrigger. The Button shows its own disabled style.
Open sub menu triggerKeeps the --color-primary-50 background while its sub menu is open.

Props

PropTypeDefaultNotes
openbooleanControlled open state. Leave it out unless you need to open or close the menu from code.
onOpenChange(open, details) => void
defaultOpenbooleanfalse
modalbooleantrueWhile open, the page does not scroll and clicks outside only close the menu.
loopFocusbooleantrueArrow keys wrap from the last item to the first.
disabledbooleanfalse
PropTypeDefaultNotes
renderelementPass <Button … />. Its props are merged with the trigger's. Children of the trigger become the Button's children.
disabledbooleanfalse
openOnHoverbooleanfalseAvoid. Menus in the product open on click.
PropTypeDefaultNotes
align"start" | "center" | "end""start"
alignOffsetnumber0
side"bottom" | "top" | "left" | "right""bottom"
sideOffsetnumber4The product uses 4 or 6, and 8 above a composer.
classNamestringWidth and height only, such as min-w-[240px] max-h-[320px].
aria-labelstringThe 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.

PropTypeDefaultNotes
onClick(event) => voidRuns when the item is chosen by mouse, Enter or Space.
variant"default" | "destructive""default"
disabledbooleanfalse
labelstringThe text used when someone types to jump to an item. Set it when the item starts with an icon.
closeOnClickbooleantrue
insetbooleanAdds 32px left padding so text lines up with items that have an icon. Also on Label, CheckboxItem, RadioItem and SubTrigger.

Other parts

PartPropsNotes
DropdownMenuCheckboxItemchecked, onCheckedChange(checked), defaultChecked, disabled, closeOnClick (default false)
DropdownMenuRadioGroupvalue, onValueChange(value), defaultValue
DropdownMenuRadioItemvalue (required), disabled, closeOnClick (default false)
DropdownMenuSubopen, onOpenChangeWraps a SubTrigger and SubContent.
DropdownMenuSubTriggerdisabled, openOnHover (default true), delay (default 100)Adds a 14px caret on the right.
DropdownMenuSubContentSame as ContentDefaults to side="right", alignOffset={-3}, sideOffset={0}, min width 96px.
DropdownMenuLabelclassName, inset12px medium --color-grey-500. Must be inside a Group.
DropdownMenuDescriptionclassName12px, 18px line height, --color-grey-500. A plain <span>.
DropdownMenuShortcutclassName12px --color-grey-500, pushed to the right. Text only.
DropdownMenuSeparatorclassName1px --color-grey-100, full width of the panel.
DropdownMenuGroupGroups items for screen readers.

Accessibility

  • The trigger gets aria-haspopup="menu" and aria-expanded. The panel has role="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 menuitemcheckbox and menuitemradio with aria-checked. Plain items with a drawn check are not, so use aria-current on 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.

On this page