Dropdown Menu
A menu of actions or options triggered by a button
Overview
A dropdown menu displays a list of actions or options in a floating panel anchored to a trigger. Use it for contextual actions, account menus, view options, and settings. It supports labels, separators, checkboxes, radio groups, keyboard shortcuts, grouping, nested submenus, and optional in-place search, and is fully keyboard navigable.
Usage
Import the components:
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@eqtylab/equality";Basic usage:
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="tertiary">
Open Menu
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem>Profile</DropdownMenuItem>
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem variant="danger">Logout</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>Variants
Item Variants
DropdownMenuItem takes a variant to mark an action’s weight. Use danger for destructive or irreversible actions, and warning for actions that are consequential but recoverable — something the user should pause over rather than avoid. Leave everything else neutral.
Colour alone shouldn’t carry the meaning, so keep the label explicit about what the action does.
<DropdownMenuContent align="start">
<DropdownMenuItem>View logs</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="warning">
Roll back to previous release
</DropdownMenuItem>
<DropdownMenuItem variant="danger">Delete deployment</DropdownMenuItem>
</DropdownMenuContent>With Separators and Labels
Use DropdownMenuLabel to title a section and DropdownMenuSeparator to divide groups of items. Items accept icons as children alongside a <span> label.
<DropdownMenuContent align="start">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<User />
<span>Profile</span>
</DropdownMenuItem>
<DropdownMenuItem>
<Settings />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
<span>Logout</span>
</DropdownMenuItem>
</DropdownMenuContent>With Checkboxes
Use DropdownMenuCheckboxItem for options that toggle on and off independently. Control each item with checked and onCheckedChange.
const [showStatusBar, setShowStatusBar] = useState(true);
<DropdownMenuCheckboxItem
checked={showStatusBar}
onCheckedChange={setShowStatusBar}
>
Status Bar
</DropdownMenuCheckboxItem>;With Radio Items
Use DropdownMenuRadioGroup with DropdownMenuRadioItem to select a single option from a set. The group is controlled with value and onValueChange.
const [position, setPosition] = useState("bottom");
<DropdownMenuRadioGroup value={position} onValueChange={setPosition}>
<DropdownMenuRadioItem value="top">Top</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="bottom">Bottom</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="right">Right</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>;With Shortcuts
Use DropdownMenuShortcut to display a keyboard shortcut hint aligned to the end of an item.
<DropdownMenuItem>
<span>New Tab</span>
<DropdownMenuShortcut>⌘T</DropdownMenuShortcut>
</DropdownMenuItem>With Submenu
Use DropdownMenuSub, DropdownMenuSubTrigger, and DropdownMenuSubContent to nest a menu inside an item. The submenu opens on hover or keyboard focus. Submenus can be nested to any depth — place another DropdownMenuSub inside a DropdownMenuSubContent to create a further level.
<DropdownMenuContent align="start">
<DropdownMenuItem>Back</DropdownMenuItem>
<DropdownMenuItem>Forward</DropdownMenuItem>
<DropdownMenuItem>Reload</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>More Tools</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Save Page As...</DropdownMenuItem>
<DropdownMenuItem>Create Shortcut...</DropdownMenuItem>
<DropdownMenuItem>Name Window...</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>Developer Tools</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Console</DropdownMenuItem>
<DropdownMenuItem>Network</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>Profiling</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Performance</DropdownMenuItem>
<DropdownMenuItem>Memory</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem>Settings</DropdownMenuItem>
</DropdownMenuContent>With Groups
Wrap related items in DropdownMenuGroup to associate a label with its items for assistive technology.
<DropdownMenuGroup>
<DropdownMenuLabel>File</DropdownMenuLabel>
<DropdownMenuItem>
<span>New File</span>
<DropdownMenuShortcut>⌘N</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<span>Open File</span>
<DropdownMenuShortcut>⌘O</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>Search & filtering
Add a DropdownMenuSearch inside DropdownMenuContent to filter items in place. It is opt-in per menu — without it, the menu behaves exactly as before and the built-in typeahead still works. Items hide themselves when they don’t match, and matching is done against each item’s textValue, falling back to its rendered text.
Generally using DropdownMenuSearch over the default typeahead is encouraged for most dropdown menus in our apps.
Import the additional parts:
import { DropdownMenuSearch, DropdownMenuEmpty } from "@eqtylab/equality";Default
The search box shows, focused, the moment the menu opens. Filtering works across every item type, including checkbox and radio items. Labels and separators hide while a search is active so results stay compact, and submenu items are flattened into the main list (see below). Add a DropdownMenuEmpty to show a “no results” row when nothing matches.
<DropdownMenuContent align="start">
<DropdownMenuSearch placeholder="Search columns..." />
<DropdownMenuEmpty>No columns found</DropdownMenuEmpty>
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={columns.email}
onCheckedChange={(checked) => setColumns({ ...columns, email: checked })}
onSelect={(event) => event.preventDefault()}
>
Email
</DropdownMenuCheckboxItem>
{/* ...more columns... */}
</DropdownMenuContent>Reveal on typing
Pass alwaysVisible={false} to keep the search box hidden until someone types into the open menu — the first keystroke reveals it and seeds the query. Nothing on screen says search exists, so keep this for compact, keyboard-heavy menus. Give items that lead with an icon or avatar a textValue so they filter on the label rather than the icon’s contents.
<DropdownMenuContent align="start">
<DropdownMenuSearch alwaysVisible={false} placeholder="Search members..." />
<DropdownMenuEmpty>No members found</DropdownMenuEmpty>
<DropdownMenuLabel>Team members</DropdownMenuLabel>
<DropdownMenuItem textValue="Ada Lovelace">
<Icon icon="User" />
<span>Ada Lovelace</span>
</DropdownMenuItem>
{/* ...more members... */}
</DropdownMenuContent>Persistent items
Mark an item persistent to keep it on screen while searching, such as a footer action that belongs to the whole list. It is never a result: it doesn’t count towards DropdownMenuEmpty or the announced result count, and in the search box skips it. A persistent separator likewise stays visible.
To act on only what the search shows, call useDropdownMenuSearchQuery() from a component inside the menu. It returns the query as typed, isSearching (whether a non-blank query is active), and matches(text), which applies the same rule the items use. Try searching for “e” below, then choosing “Hide all”.
import { useDropdownMenuSearchQuery } from "@eqtylab/equality";
const ColumnVisibilityFooter = ({ onShow }) => {
const { matches } = useDropdownMenuSearchQuery();
const shown = Object.keys(COLUMN_LABELS).filter((key) =>
matches(COLUMN_LABELS[key]),
);
return (
<>
<DropdownMenuSeparator persistent />
<DropdownMenuItem
persistent
onSelect={(event) => {
event.preventDefault();
onShow(shown, true);
}}
>
Show all
</DropdownMenuItem>
<DropdownMenuItem
persistent
onSelect={(event) => {
event.preventDefault();
onShow(shown, false);
}}
>
Hide all
</DropdownMenuItem>
</>
);
};Render it inside DropdownMenuContent, after the items it acts on:
<DropdownMenuContent align="start">
<DropdownMenuSearch placeholder="Search columns..." />
<DropdownMenuEmpty>No columns found</DropdownMenuEmpty>
{Object.entries(COLUMN_LABELS).map(([key, label]) => (
<DropdownMenuCheckboxItem
key={key}
checked={columns[key]}
onCheckedChange={(checked) =>
setColumns((prev) => ({ ...prev, [key]: checked }))
}
onSelect={(event) => event.preventDefault()}
>
{label}
</DropdownMenuCheckboxItem>
))}
<ColumnVisibilityFooter
onShow={(keys, visible) =>
setColumns((prev) => ({
...prev,
...Object.fromEntries(keys.map((key) => [key, visible])),
}))
}
/>
</DropdownMenuContent>useDropdownMenuSearchQuery() only works inside a DropdownMenu (it reads the search via context) and throws otherwise.
Searching submenus
Items nested in a DropdownMenuSub are flattened into the main list while searching, so submenu items appear in the results without opening the submenu. Each flattened item is prefixed with its submenu path (e.g. More Tools › Save Page As…), dimmed like a keyboard shortcut so the item’s own label stays legible. Nested submenus stack the full path. Try searching for “console” below.
<DropdownMenuContent align="start">
<DropdownMenuSearch placeholder="Search actions..." />
<DropdownMenuEmpty>No actions found</DropdownMenuEmpty>
<DropdownMenuItem>Cut</DropdownMenuItem>
<DropdownMenuItem>Copy</DropdownMenuItem>
<DropdownMenuItem>Paste</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>More Tools</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Save Page As...</DropdownMenuItem>
<DropdownMenuItem>Create Shortcut...</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span>Developer Tools</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Console</DropdownMenuItem>
<DropdownMenuItem>Network</DropdownMenuItem>
<DropdownMenuItem>Task Manager</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>Flattening only works when DropdownMenuSubContent is placed directly inside DropdownMenuSub — wrapping it in another element prevents its items from being searched.
Keyboard
in the search box activates the first matching item, skipping
persistent ones.
moves from the search box to the first item and to the
last; on the first item and on the last return to the
search box, so the arrows cycle through it. Typing or
while an item is focused keeps editing the query.
Accessibility
The search box and its live regions sit inside the menu, which Radix gives the menu role. ARIA only allows menu items, groups and separators there, so automated checkers such as axe report aria-required-children, and some screen readers skip the search box in browse mode. They can’t move outside the menu: while it is open, Radix hides everything outside it from assistive technology.
To pick a value in a form rather than run an action, use a Select with search .
Slots
| Name | Description |
|---|---|
DropdownMenu | Root component, manages open state |
DropdownMenuTrigger | Element that opens the menu on click |
DropdownMenuContent | Floating panel containing the menu items |
DropdownMenuSearch | Optional search input that filters items in place |
DropdownMenuEmpty | ”No results” row shown only when nothing matches |
DropdownMenuItem | A single actionable menu item |
DropdownMenuCheckboxItem | A toggleable item with a checkmark indicator |
DropdownMenuRadioGroup | Groups radio items into a single-select set |
DropdownMenuRadioItem | A single-select item within a radio group |
DropdownMenuLabel | Non-interactive section title |
DropdownMenuSeparator | Divider between groups of items |
DropdownMenuShortcut | Keyboard shortcut hint aligned to the item’s end |
DropdownMenuGroup | Groups related items for assistive technology |
DropdownMenuSub | Root for a nested submenu |
DropdownMenuSubTrigger | Item that opens a nested submenu |
DropdownMenuSubContent | Floating panel for a nested submenu |
Props
DropdownMenu
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
open | Controls the open state | boolean | - | ❌ |
defaultOpen | Open state when initially rendered | boolean | - | ❌ |
onOpenChange | Called when the open state changes | (open: boolean) => void | - | ❌ |
DropdownMenuTrigger
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
asChild | Merge props onto the child instead of rendering a button | boolean | false | ❌ |
DropdownMenuContent
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
align | Alignment against the trigger | start, center, end | center | ❌ |
side | Side of the trigger to render on | top, right, bottom, left | bottom | ❌ |
sideOffset | Distance in pixels from the trigger | number | 4 | ❌ |
DropdownMenuItem
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
variant | Visual style; warning marks a consequential action, danger a destructive one | neutral, warning, danger | neutral | ❌ |
inset | Adds left padding to align with items that have icons | boolean | false | ❌ |
disabled | Prevents interaction and dims the item | boolean | false | ❌ |
textValue | Text used for search filtering; falls back to the item’s rendered text | string | - | ❌ |
persistent | Stays visible while searching, and is never a result | boolean | false | ❌ |
onSelect | Called when the item is selected | () => void | - | ❌ |
DropdownMenuCheckboxItem
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
checked | Whether the item is checked | boolean | - | ❌ |
onCheckedChange | Called when the checked state changes | (checked: boolean) => void | - | ❌ |
disabled | Prevents interaction and dims the item | boolean | false | ❌ |
textValue | Text used for search filtering; falls back to the item’s rendered text | string | - | ❌ |
persistent | Stays visible while searching, and is never a result | boolean | false | ❌ |
DropdownMenuRadioGroup
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
value | The value of the selected item | string | - | ❌ |
onValueChange | Called when the selected value changes | (value: string) => void | - | ❌ |
DropdownMenuRadioItem
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
value | The unique value of the item | string | - | ✅ |
disabled | Prevents interaction and dims the item | boolean | false | ❌ |
textValue | Text used for search filtering; falls back to the item’s rendered text | string | - | ❌ |
persistent | Stays visible while searching, and is never a result | boolean | false | ❌ |
DropdownMenuLabel & DropdownMenuSubTrigger
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
inset | Adds left padding to align with items that have icons | boolean | false | ❌ |
DropdownMenuSeparator
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
persistent | Stays visible while searching | boolean | false | ❌ |
DropdownMenuSearch
Also accepts standard input attributes, except value and onChange, which are managed internally.
| Name | Description | Type | Default | Required |
|---|---|---|---|---|
alwaysVisible | Show the input on open; false reveals it on the first keystroke instead | boolean | true | ❌ |
placeholder | Placeholder text for the input | string | Search... | ❌ |
icon | Custom leading icon; defaults to a search icon | ReactNode | - | ❌ |
aria-label | Accessible name for the input; defaults to the placeholder text | string | placeholder | ❌ |
ref | Forwarded to the underlying <input>; null while the input is not rendered | Ref<HTMLInputElement> | - | ❌ |
DropdownMenuEmpty
Renders its children as a “no results” message, shown only while a search query matches no items. It is a live region (role="status"), so the message is announced when filtering empties the list. Also accepts standard div attributes.