EQTY Lab Equality

Dropdown Menu

A menu of actions or options triggered by a button

View as Markdown

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:

ts
import {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuItem,
} from "@eqtylab/equality";

Basic usage:

jsx
<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.

jsx
<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.

jsx
<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.

jsx
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.

jsx
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.

jsx
<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.

jsx
<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.

jsx
<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:

ts
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.

jsx
<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.

jsx
<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”.

jsx
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:

jsx
<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.

jsx
<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

NameDescription
DropdownMenuRoot component, manages open state
DropdownMenuTriggerElement that opens the menu on click
DropdownMenuContentFloating panel containing the menu items
DropdownMenuSearchOptional search input that filters items in place
DropdownMenuEmpty”No results” row shown only when nothing matches
DropdownMenuItemA single actionable menu item
DropdownMenuCheckboxItemA toggleable item with a checkmark indicator
DropdownMenuRadioGroupGroups radio items into a single-select set
DropdownMenuRadioItemA single-select item within a radio group
DropdownMenuLabelNon-interactive section title
DropdownMenuSeparatorDivider between groups of items
DropdownMenuShortcutKeyboard shortcut hint aligned to the item’s end
DropdownMenuGroupGroups related items for assistive technology
DropdownMenuSubRoot for a nested submenu
DropdownMenuSubTriggerItem that opens a nested submenu
DropdownMenuSubContentFloating panel for a nested submenu

Props

NameDescriptionTypeDefaultRequired
openControls the open stateboolean-❌
defaultOpenOpen state when initially renderedboolean-❌
onOpenChangeCalled when the open state changes(open: boolean) => void-❌
NameDescriptionTypeDefaultRequired
asChildMerge props onto the child instead of rendering a buttonbooleanfalse❌
NameDescriptionTypeDefaultRequired
alignAlignment against the triggerstart, center, endcenter❌
sideSide of the trigger to render ontop, right, bottom, leftbottom❌
sideOffsetDistance in pixels from the triggernumber4❌
NameDescriptionTypeDefaultRequired
variantVisual style; warning marks a consequential action, danger a destructive oneneutral, warning, dangerneutral❌
insetAdds left padding to align with items that have iconsbooleanfalse❌
disabledPrevents interaction and dims the itembooleanfalse❌
textValueText used for search filtering; falls back to the item’s rendered textstring-❌
persistentStays visible while searching, and is never a resultbooleanfalse❌
onSelectCalled when the item is selected() => void-❌
NameDescriptionTypeDefaultRequired
checkedWhether the item is checkedboolean-❌
onCheckedChangeCalled when the checked state changes(checked: boolean) => void-❌
disabledPrevents interaction and dims the itembooleanfalse❌
textValueText used for search filtering; falls back to the item’s rendered textstring-❌
persistentStays visible while searching, and is never a resultbooleanfalse❌
NameDescriptionTypeDefaultRequired
valueThe value of the selected itemstring-❌
onValueChangeCalled when the selected value changes(value: string) => void-❌
NameDescriptionTypeDefaultRequired
valueThe unique value of the itemstring-✅
disabledPrevents interaction and dims the itembooleanfalse❌
textValueText used for search filtering; falls back to the item’s rendered textstring-❌
persistentStays visible while searching, and is never a resultbooleanfalse❌
NameDescriptionTypeDefaultRequired
insetAdds left padding to align with items that have iconsbooleanfalse❌
NameDescriptionTypeDefaultRequired
persistentStays visible while searchingbooleanfalse❌

Also accepts standard input attributes, except value and onChange, which are managed internally.

NameDescriptionTypeDefaultRequired
alwaysVisibleShow the input on open; false reveals it on the first keystroke insteadbooleantrue❌
placeholderPlaceholder text for the inputstringSearch...❌
iconCustom leading icon; defaults to a search iconReactNode-❌
aria-labelAccessible name for the input; defaults to the placeholder textstringplaceholder❌
refForwarded to the underlying <input>; null while the input is not renderedRef<HTMLInputElement>-❌

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.