Qure UI
Components

Dropdown Menu

A list of actions hung off a button — the row overflow, the account menu, the sort control.

'use client'import { DownloadIcon, FileTextIcon, MoreHorizontalIcon, ShareIcon, Trash2Icon } from 'lucide-react'import { Button } from '@/registry/qure/ui/button'import {  DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem,  DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger,} from '@/registry/qure/ui/dropdown-menu'export default function DropdownMenuDemo() {  return (    <DropdownMenu>      <DropdownMenuTrigger        render={          <Button variant="tertiary" size="icon-md" aria-label="Study actions">            <MoreHorizontalIcon className="size-4" />          </Button>        }      />      <DropdownMenuContent className="min-w-64">        <DropdownMenuGroup>          <DropdownMenuLabel>ACC-4471902</DropdownMenuLabel>          <DropdownMenuItem>            <FileTextIcon className="size-4" />            Open report            <DropdownMenuShortcut>⇧R</DropdownMenuShortcut>          </DropdownMenuItem>          <DropdownMenuItem>            <DownloadIcon className="size-4" />            Download DICOM          </DropdownMenuItem>          <DropdownMenuItem>            <ShareIcon className="size-4" />            Share with referrer          </DropdownMenuItem>        </DropdownMenuGroup>        <DropdownMenuSeparator />        <DropdownMenuItem variant="destructive">          <Trash2Icon className="size-4" />          Remove from worklist        </DropdownMenuItem>      </DropdownMenuContent>    </DropdownMenu>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/dropdown-menu.json

Usage

import {
  DropdownMenu, DropdownMenuContent, DropdownMenuItem,
  DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
<DropdownMenu>
  <DropdownMenuTrigger render={<Button variant="tertiary">Actions</Button>} />
  <DropdownMenuContent>
    <DropdownMenuItem>Open report</DropdownMenuItem>
    <DropdownMenuSeparator />
    <DropdownMenuItem variant="destructive">Remove from worklist</DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

A menu holds actions — things that happen. A Select holds values — things that are chosen and then persist in the trigger. If, after clicking, the button should show what you picked, you wanted a select.

Composition

<DropdownMenu>                 {/* owns the open state */}
  <DropdownMenuTrigger>        {/* the button, and the anchor */}
  <DropdownMenuContent>        {/* portal + positioner + popup */}
    <DropdownMenuGroup>        {/* a section of related items */}
      <DropdownMenuLabel>      {/* its heading; not an item, not focusable */}
      <DropdownMenuItem>       {/* an action */}
        <DropdownMenuItemText>       {/* label column, when there is a description */}
          <DropdownMenuDescription>  {/* the second line */}
        <DropdownMenuShortcut>       {/* the key hint, pushed to the trailing edge */}
    </DropdownMenuGroup>
    <DropdownMenuSeparator>    {/* a rule, full bleed to the popup's edges */}
    <DropdownMenuCheckboxItem> {/* a setting that toggles; keeps the menu open */}
    <DropdownMenuRadioGroup>   {/* one-of-many, with DropdownMenuRadioItem */}
    <DropdownMenuSub>          {/* a nested menu */}
      <DropdownMenuSubTrigger> {/* the row that opens it; renders its own chevron */}
      <DropdownMenuSubContent> {/* the nested popup, to the right by default */}

DropdownMenuItemText exists for one reason: it puts the description under the label rather than under the icon, which is how the Figma item is drawn. An item with no description does not need it — put the label straight into DropdownMenuItem.

DropdownMenuLabel is Base UI's Menu.GroupLabel, and it throws unless it is inside a DropdownMenuGroup or a DropdownMenuRadioGroup — "MenuGroupContext is missing" at runtime, not at compile time. A heading in a menu names a group, so put the items it names inside the group with it.

Actions on a worklist row

'use client'import { DownloadIcon, FileTextIcon, MoreHorizontalIcon, ShareIcon, Trash2Icon } from 'lucide-react'import { Button } from '@/registry/qure/ui/button'import {  DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem,  DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuTrigger,} from '@/registry/qure/ui/dropdown-menu'export default function DropdownMenuDemo() {  return (    <DropdownMenu>      <DropdownMenuTrigger        render={          <Button variant="tertiary" size="icon-md" aria-label="Study actions">            <MoreHorizontalIcon className="size-4" />          </Button>        }      />      <DropdownMenuContent className="min-w-64">        <DropdownMenuGroup>          <DropdownMenuLabel>ACC-4471902</DropdownMenuLabel>          <DropdownMenuItem>            <FileTextIcon className="size-4" />            Open report            <DropdownMenuShortcut>⇧R</DropdownMenuShortcut>          </DropdownMenuItem>          <DropdownMenuItem>            <DownloadIcon className="size-4" />            Download DICOM          </DropdownMenuItem>          <DropdownMenuItem>            <ShareIcon className="size-4" />            Share with referrer          </DropdownMenuItem>        </DropdownMenuGroup>        <DropdownMenuSeparator />        <DropdownMenuItem variant="destructive">          <Trash2Icon className="size-4" />          Remove from worklist        </DropdownMenuItem>      </DropdownMenuContent>    </DropdownMenu>  )}

The overflow menu behind a button. Destructive items take variant="destructive" and go below a separator, so that the muscle memory for "second from the bottom" never lands on delete.

Items with descriptions

'use client'import { CheckCircle2Icon, PenLineIcon, SendIcon } from 'lucide-react'import { Button } from '@/registry/qure/ui/button'import {  DropdownMenu, DropdownMenuContent, DropdownMenuDescription, DropdownMenuItem,  DropdownMenuItemText, DropdownMenuShortcut, DropdownMenuTrigger,} from '@/registry/qure/ui/dropdown-menu'export default function DropdownMenuDescriptionDemo() {  return (    <DropdownMenu>      <DropdownMenuTrigger render={<Button variant="secondary">Finish read</Button>} />      <DropdownMenuContent className="w-[22rem]">        <DropdownMenuItem>          <PenLineIcon className="size-4" />          <DropdownMenuItemText>            Save as preliminary            <DropdownMenuDescription>              Visible to the ward, still editable by you.            </DropdownMenuDescription>          </DropdownMenuItemText>          <DropdownMenuShortcut>⇧P</DropdownMenuShortcut>        </DropdownMenuItem>        <DropdownMenuItem>          <CheckCircle2Icon className="size-4" />          <DropdownMenuItemText>            Sign and finalise            <DropdownMenuDescription>              Locks the report. An addendum is the only way back.            </DropdownMenuDescription>          </DropdownMenuItemText>          <DropdownMenuShortcut>⇧S</DropdownMenuShortcut>        </DropdownMenuItem>        <DropdownMenuItem disabled>          <SendIcon className="size-4" />          <DropdownMenuItemText>            Send to second reader            <DropdownMenuDescription>              Unavailable — no second reader is rostered tonight.            </DropdownMenuDescription>          </DropdownMenuItemText>        </DropdownMenuItem>      </DropdownMenuContent>    </DropdownMenu>  )}

Where the consequence of an action is not obvious from three words, spend the second line. "Locks the report. An addendum is the only way back" is the difference between an informed click and a support ticket.

Toggling settings

'use client'import * as React from 'react'import { SlidersHorizontalIcon } from 'lucide-react'import { Button } from '@/registry/qure/ui/button'import {  DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent,  DropdownMenuGroup, DropdownMenuLabel, DropdownMenuTrigger,} from '@/registry/qure/ui/dropdown-menu'export default function DropdownMenuCheckbox() {  const [columns, setColumns] = React.useState({    accession: true,    modality: true,    priority: true,    referrer: false,  })  const toggle = (key: keyof typeof columns) => (checked: boolean) =>    setColumns(current => ({ ...current, [key]: checked }))  return (    <DropdownMenu>      <DropdownMenuTrigger        render={          <Button variant="tertiary">            <SlidersHorizontalIcon className="size-4" />            Columns          </Button>        }      />      <DropdownMenuContent className="min-w-56">        <DropdownMenuGroup>          <DropdownMenuLabel>Worklist columns</DropdownMenuLabel>          <DropdownMenuCheckboxItem checked={columns.accession} onCheckedChange={toggle('accession')}>            Accession number          </DropdownMenuCheckboxItem>          <DropdownMenuCheckboxItem checked={columns.modality} onCheckedChange={toggle('modality')}>            Modality          </DropdownMenuCheckboxItem>          <DropdownMenuCheckboxItem checked={columns.priority} onCheckedChange={toggle('priority')}>            Priority          </DropdownMenuCheckboxItem>          <DropdownMenuCheckboxItem checked={columns.referrer} onCheckedChange={toggle('referrer')}>            Referring clinician          </DropdownMenuCheckboxItem>        </DropdownMenuGroup>      </DropdownMenuContent>    </DropdownMenu>  )}

DropdownMenuCheckboxItem defaults to closeOnClick={false}, so four columns can be toggled without reopening the menu four times.

One of several

'use client'import * as React from 'react'import { ArrowUpDownIcon } from 'lucide-react'import { Button } from '@/registry/qure/ui/button'import {  DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup,  DropdownMenuRadioItem, DropdownMenuTrigger,} from '@/registry/qure/ui/dropdown-menu'export default function DropdownMenuRadio() {  const [sort, setSort] = React.useState('priority')  return (    <DropdownMenu>      <DropdownMenuTrigger        render={          <Button variant="tertiary">            <ArrowUpDownIcon className="size-4" />            Sort          </Button>        }      />      <DropdownMenuContent className="min-w-56">        {/* The label lives inside the radio group: Base UI ties the two            together, and a GroupLabel with no group above it throws. */}        <DropdownMenuRadioGroup value={sort} onValueChange={setSort}>          <DropdownMenuLabel>Order the worklist by</DropdownMenuLabel>          <DropdownMenuRadioItem value="priority">Priority, then age</DropdownMenuRadioItem>          <DropdownMenuRadioItem value="acquired">Time acquired</DropdownMenuRadioItem>          <DropdownMenuRadioItem value="waiting">Time waiting</DropdownMenuRadioItem>          <DropdownMenuRadioItem value="modality">Modality</DropdownMenuRadioItem>        </DropdownMenuRadioGroup>      </DropdownMenuContent>    </DropdownMenu>  )}

A radio group inside a menu is the right shape for a sort order or a view mode: exactly one is active, and the menu is the only place it is set.

'use client'import { ClockIcon, UserPlusIcon } from 'lucide-react'import { Button } from '@/registry/qure/ui/button'import {  DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,  DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,} from '@/registry/qure/ui/dropdown-menu'export default function DropdownMenuSubmenu() {  return (    <DropdownMenu>      <DropdownMenuTrigger render={<Button variant="tertiary">Triage</Button>} />      <DropdownMenuContent className="min-w-56">        <DropdownMenuSub>          <DropdownMenuSubTrigger>            <UserPlusIcon className="size-4" />            Assign to          </DropdownMenuSubTrigger>          <DropdownMenuSubContent className="min-w-56">            <DropdownMenuItem>Dr A. Rao — 4 in queue</DropdownMenuItem>            <DropdownMenuItem>Dr M. Iyer — 11 in queue</DropdownMenuItem>            <DropdownMenuItem>Dr S. Khan — off shift</DropdownMenuItem>            <DropdownMenuSeparator />            <DropdownMenuItem>Return to the unassigned pool</DropdownMenuItem>          </DropdownMenuSubContent>        </DropdownMenuSub>        <DropdownMenuSub>          <DropdownMenuSubTrigger>            <ClockIcon className="size-4" />            Set priority          </DropdownMenuSubTrigger>          <DropdownMenuSubContent className="min-w-48">            <DropdownMenuItem>Routine</DropdownMenuItem>            <DropdownMenuItem>Urgent</DropdownMenuItem>            <DropdownMenuItem variant="destructive">STAT</DropdownMenuItem>          </DropdownMenuSubContent>        </DropdownMenuSub>      </DropdownMenuContent>    </DropdownMenu>  )}

Nest one level, and only when the second level is a genuine sublist — a roster, a set of priors. Two levels of nesting in a menu is a navigation tree wearing a disguise.

How highlight works

Base UI puts data-highlighted on the active row, whether it got there by pointer or by arrow key, and keeps only one row lit at a time. Styling :hover instead is the classic bug: the keyboard user sees nothing move.

.cn-dropdown-menu-item[data-highlighted] {
  background-color: var(--background-brand-default);
  color: var(--text-brand-on-brand);
}

The full teal fill is Figma's hover state for Menu Item (node 48:20821), not a liberty.

The trigger must be a real button. DropdownMenuTrigger renders one by default; if you replace it with something else through render, pass nativeButton={false} so Base UI wires up the keyboard handling it can no longer assume.

Keyboard

Enter, Space or Down opens the menu and lands on the first item. Arrows move, typing jumps to a matching label, Right opens a submenu and Left leaves it, Escape closes and returns focus to the trigger.

API Reference

Everything Base UI's Menu accepts. What we add on DropdownMenuItem:

Prop

Type

On DropdownMenuContent:

Prop

Type

Worth knowing, from the primitive:

PropWhereNote
closeOnClickItemtrue on actions, false on checkbox and radio items.
modalRoottrue by default: the page behind is inert while the menu is open.
openOnHoverTrigger, SubmenuTriggerOff for the root trigger, and best left off.
loopFocusRoottrue; arrowing past the last item returns to the first.

On this page