Dropdown Menu
A menu of actions or options, opened from a button.
Last action: none · Status bar on · Panel bottom
Codeapp/demos/public/dropdown-menu.tsx
import { clientEntry, type Handle } from 'remix/component'
import { buttonVariants } from '@stemsui/registry/ui/button.tsx'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuEmpty,
DropdownMenuFilterInput,
DropdownMenuFilterProvider,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuList,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
onMenuSelect,
} from '@stemsui/registry/ui/dropdown-menu.tsx'
export const DropdownMenuDemo = clientEntry(
import.meta.url,
function DropdownMenuDemo(handle: Handle) {
let lastAction = 'none'
let showStatusBar = true
let position = 'bottom'
return () => (
<div
class="flex flex-col items-center gap-4"
mix={onMenuSelect((event) => {
if (event.kind === 'item') {
lastAction = String(event.value ?? event.label)
void handle.update()
}
})}
>
<DropdownMenu>
<DropdownMenuTrigger class={buttonVariants({ variant: 'outline' })}>
Open menu
</DropdownMenuTrigger>
<DropdownMenuContent class="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuItem value="profile">
Profile
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem value="billing">
Billing
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem value="settings">
Settings
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem value="team">Team</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>Invite users</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem value="invite-email">Email</DropdownMenuItem>
<DropdownMenuItem value="invite-message">Message</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem value="invite-more">More...</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem value="new-team">
New Team
<DropdownMenuShortcut>⌘+T</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={showStatusBar}
onCheckedChange={(checked) => {
showStatusBar = checked
void handle.update()
}}
>
Status bar
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
<DropdownMenuRadioGroup
value={position}
onValueChange={(value) => {
position = String(value)
void handle.update()
}}
>
<DropdownMenuLabel>Panel position</DropdownMenuLabel>
<DropdownMenuRadioItem value="top">Top</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="bottom">Bottom</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="right">Right</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem value="api" disabled>
API
</DropdownMenuItem>
<DropdownMenuItem value="logout" variant="destructive">
Log out
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<FilterableExample />
<p class="text-sm text-muted-foreground" data-testid="menu-result">
Last action: <span class="font-medium text-foreground">{lastAction}</span> · Status bar{' '}
{showStatusBar ? 'on' : 'off'} · Panel {position}
</p>
</div>
)
},
)
const recentFolders = ['Inbox', 'Drafts', 'Archive']
const allFolders = ['Projects', 'Receipts', 'Résumés', 'Photos', 'Travel', 'Taxes 2026']
/**
* A filterable menu: type to narrow the folders, use the arrow keys while focus stays in the
* input, and press Enter to pick the highlighted one.
*/
function FilterableExample() {
return () => (
<DropdownMenuFilterProvider autoHighlight>
<DropdownMenu>
<DropdownMenuTrigger class={buttonVariants({ variant: 'outline' })}>
Move to folder…
</DropdownMenuTrigger>
{/* Its own data-slot keeps the first demo's `dropdown-menu-content` selector unique. */}
<DropdownMenuContent data-slot="dropdown-menu-filter-content" class="w-60">
<DropdownMenuFilterInput
aria-label="Filter folders"
placeholder="Filter folders…"
showClear
data-testid="menu-filter-input"
/>
<DropdownMenuEmpty data-testid="menu-filter-empty">No folders found.</DropdownMenuEmpty>
<DropdownMenuList>
<DropdownMenuGroup>
<DropdownMenuLabel>Recent</DropdownMenuLabel>
{recentFolders.map((folder) => (
<DropdownMenuItem value={`move:${folder}`}>{folder}</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuGroup>
<DropdownMenuLabel>All folders</DropdownMenuLabel>
{allFolders.map((folder) => (
<DropdownMenuItem value={`move:${folder}`}>{folder}</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuList>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuFilterProvider>
)
}
Installation
npx shadcn add @stemsui/dropdown-menuRequires the one-time setup. Behavior: stems-ui/menu.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/menu
menu wires a trigger button to a popup list of actions: menu roles, keyboard navigation with
typeahead, highlight on hover, checkbox and radio items, submenus, and the popover machinery
(top layer, positioning, dismissal, focus, enter/exit animation hooks). App code owns the markup
and visual styles.
Base UI equivalent: Menu. Context menus are
stems-ui/context-menu, which shares every part below.
Usage
import * as menu from 'stems-ui/menu'
let itemClass =
'flex px-3 py-1.5 outline-none data-highlighted:bg-gray-100 data-disabled:opacity-50'
function ViewMenu(handle: Handle) {
let sort = 'date'
return () => (
<menu.Context>
<button mix={menu.trigger()}>View</button>
<div
class="min-w-40 rounded-md bg-white p-1 shadow-md data-starting-style:opacity-0 data-ending-style:opacity-0 transition-opacity"
mix={menu.popup({ align: 'start', sideOffset: 4 })}
>
<div class={itemClass} mix={menu.item({ value: 'reload' })}>
Reload
</div>
<div class="my-1 h-px bg-gray-200" mix={menu.separator()} />
<menu.CheckboxItemContext defaultChecked>
<div class={itemClass} mix={menu.checkboxItem()}>
<span mix={menu.checkboxItemIndicator()}>✓</span> Word wrap
</div>
</menu.CheckboxItemContext>
<menu.RadioGroupContext
value={sort}
onValueChange={(value) => {
sort = String(value)
void handle.update()
}}
>
<div mix={menu.radioGroup()}>
<div mix={menu.groupLabel()}>Sort by</div>
<menu.RadioItemContext value="date">
<div class={itemClass} mix={menu.radioItem()}>
<span mix={menu.radioItemIndicator()}>•</span> Date
</div>
</menu.RadioItemContext>
<menu.RadioItemContext value="name">
<div class={itemClass} mix={menu.radioItem()}>
<span mix={menu.radioItemIndicator()}>•</span> Name
</div>
</menu.RadioItemContext>
</div>
</menu.RadioGroupContext>
<menu.SubmenuContext>
<div class={itemClass} mix={menu.submenuTrigger()}>
Zoom ›
</div>
<div class="rounded-md bg-white p-1 shadow-md" mix={menu.popup()}>
<div class={itemClass} mix={menu.item({ value: 'zoom-in' })}>
Zoom in
</div>
<div class={itemClass} mix={menu.item({ value: 'zoom-out' })}>
Zoom out
</div>
</div>
</menu.SubmenuContext>
<a class={itemClass} href="/settings" mix={menu.linkItem()}>
Settings
</a>
</div>
</menu.Context>
)
}
Listen for activations anywhere above the items:
<div mix={menu.onMenuSelect((event) => console.log(event.kind, event.value, event.checked))}>…</div>
Differences from Base UI
- No Portal, no Positioner element. The popup is shown in the top layer with
popover="manual"; Base UI's Positioner props are options ofpopup()and the positioner's data attributes / CSS variables are set on the popup. - Item state lives in providers. A mixin can't provide context to its children, so the
stateful items are split like
accordion.ItemContext:CheckboxItemContext(Base UI CheckboxItem'schecked/defaultChecked/onCheckedChange) +checkboxItem(),RadioGroupContext(RadioGroup'svalue/defaultValue/onValueChange) +radioGroup(),RadioItemContext(RadioItem'svalue) +radioItem(). The indicators read the nearest item provider. - Submenus must be rendered inside the parent's popup (in the DOM):
SubmenuContext= Base UISubmenuRoot. - Indicators always stay in the DOM (
hiddenwhile unchecked).keepMountedkeeps them visible instead. MenuSelectEvent(stems:menu-select) is a Stems extra (Remix hadonMenuSelect); items accept avalueoption reported by it.- Filtering (Base UI 1.9
Menu.FilterProvider/Input/Clear/Empty/List): see Filtering.Emptyis a live region that stays rendered (put the message inEmptyText), andCleargetshiddeninstead of unmounting.list()without aFilterProvideris a plainrole="presentation"wrapper (the popup keepsrole="menu"). - Not supported: the Viewport part,
actionsRef,data-instant,--positioner-width/height, the'sibling-open'/'cancel-open'reasons (a sibling opening reports'none'), opening on pointer down with press-drag-release selection, and outside-pointer blocking beyondinert.
stems-ui/menu
Providers
Context(Base UIMenu.Root): owns the open state. Read it withhandle.context.get(menu.Context)(open,setOpen(open, { reason }),close(),highlighted,store, …).open/defaultOpen/onOpenChange(open, details):detailshasreason,event,trigger,cancel().onOpenChangeComplete(open): after the enter / exit animation.modal:true(default) |false|'trap-focus'.truelocks page scroll and makes outside contentinert. A menu opened by hovering its trigger is never modal.loopFocus:boolean, defaulttrue. Arrow keys wrap at the ends.orientation:'vertical'(default) |'horizontal'. Which arrows move between items.disabled: disables the trigger.highlightItemOnHover:boolean, defaulttrue.onItemHighlighted(item | undefined, { reason: 'keyboard' | 'pointer' | 'none', event, label }).
SubmenuContext(Base UIMenu.SubmenuRoot): same props exceptmodal(submenus are never modal), pluscloseParentOnEsc(defaultfalse: Escape closes only the submenu).CheckboxItemContext:checked/defaultChecked(defaultfalse) /onCheckedChange(checked, details)(details.cancel()vetoes),disabled.RadioGroupContext:value/defaultValue/onValueChange(value, details),disabled.RadioItemContext:value(required),disabled.
Mixins
trigger(options?):type="button",id,aria-haspopup="menu",aria-expanded,aria-controls. Options:disabled,openOnHover,delay(default100),closeDelay(default0).popup(options?):role="menu",aria-labelledby(the trigger),aria-orientationwhen horizontal,popover="manual",tabIndex={-1},hiddenwhile closed. Options:side(default'bottom'; submenus'inline-end'),align(default'center'; submenus'start'),sideOffset,alignOffset,collisionPadding,arrowPadding,anchor,initialFocus,finalFocus(see popover).arrow(),backdrop(): as in popover.item(options?):role="menuitem",tabIndex={-1}. Options:label(typeahead text, defaults to the text content),disabled,closeOnClick(defaulttrue),value.linkItem(options?): for an<a href>. Options:label,closeOnClick(defaultfalse).checkboxItem(options?):role="menuitemcheckbox",aria-checked. Options:label,closeOnClick(defaultfalse),value.checkboxItemIndicator(options?)/radioItemIndicator(options?):aria-hidden;hiddenonce unchecked (after its exit animation) unlesskeepMounted.radioGroup():role="group".radioItem(options?):role="menuitemradio",aria-checked. Options:label,closeOnClick(defaultfalse).group():role="group",aria-labelledbyitsgroupLabel()(set after hydration).groupLabel(): anidfor the enclosing group.separator(options?): the genericstems-ui/separatormixin.submenuTrigger(options?):role="menuitem",aria-haspopup="menu",aria-expanded,aria-controls. Options:label,disabled,openOnHover(defaulttrue),delay(default100),closeDelay(default0).
Events
MenuChangeEvent(stems:menu-change,onMenuChange(handler)): bubbles from the active trigger (a submenu's from its submenu trigger) withopenandreason.MenuSelectEvent(stems:menu-select,onMenuSelect(handler)): bubbles from an activated item, after checkbox / radio state changed, withitem(the element),kind('item'|'checkbox-item'|'radio-item'|'link-item'),label,value(the radio value; thehreffor links),checked, andcloseOnClick. It is cancelable:preventDefault()keeps the menu open.
Change reasons: 'trigger-press', 'trigger-hover', 'outside-press', 'escape-key',
'item-press', 'list-navigation', 'focus-out', 'imperative-action', 'none'.
Detached triggers
Base UI's createHandle(): triggers can live anywhere in the document, outside the Context,
linked by a handle. Each trigger can pass a payload, so one popup can serve many triggers with
different data.
import * as menu from 'stems-ui/menu'
let people = menu.createHandle<{ name: string }>()
// Anywhere, e.g. in a table row:
<button mix={menu.trigger({ handle: people, payload: { name: 'Ada' } })}>Ada</button>
<button mix={menu.trigger({ handle: people, payload: { name: 'Grace' } })}>Grace</button>
// Elsewhere:
<menu.Context handle={people}>
{({ payload }) => <div mix={menu.popup()}>{payload?.name}</div>}
</menu.Context>
createHandle<Payload>()returns the handle. Pass it to theContext(handleprop, read once) and to each detachedtrigger({ handle, payload })(read on the trigger's first render).Payloadtypespayloadin theContext's children function.- Detached triggers register with the handle, so they behave like triggers inside the
Context:aria-expanded,aria-controls,data-popup-open, anddata-pressedfollow the menu, the popup is labelled by the active trigger, ArrowDown / ArrowUp on a trigger open the menu, the popup anchors to the active trigger, focus returns to it, and a press on another trigger counts as inside (it moves the popup instead of dismissing it). - The active trigger's
payloadis{ payload }in a children render function (re-run each time theContextrenders) andpayloadon the context value (handle.context.get(menu.Context).payload). It'sundefineduntil a trigger has been active, and stays while the popup animates out. A children function only works with JSX (createElementrejects function children).payloadalso works on triggers inside theContext. - Handle methods:
isOpen,open(triggerId?)(opens, or moves an open popup, to the trigger with thatid; reason'imperative-action'),close(). They act on the mountedContextand are ignored while none is mounted. A remountedContextstarts fresh. ContextpropstriggerId(controlled, pair withopen; storedetails.trigger?.idfromonOpenChange) anddefaultTriggerIdpick the active trigger by elementid.- Detached triggers aren't re-rendered by the
Context; they update their state attributes directly when it renders. On the server nothing is shared (a handle is usually a module-level singleton), so a detached trigger renders closed and withoutaria-controls; it getsaria-controlsonce theContextmounts in the browser. - Only top-level
menu.Contexttakes ahandle(not submenus, context menus, or menus inside amenubar.Context).
Filtering
<menu.FilterProvider autoHighlight>
<menu.Context>
<button mix={menu.trigger()}>Move to…</button>
<div mix={menu.popup()}>
<input aria-label="Filter folders" mix={menu.input()} />
<button mix={menu.clear()}>Clear</button>
<div mix={menu.empty()}>
<menu.EmptyText>No folders found.</menu.EmptyText>
</div>
<div mix={menu.list()}>
<div mix={menu.item()}>Inbox</div>
<div mix={menu.item({ label: 'Résumés' })}>CVs</div>
</div>
</div>
</menu.Context>
</menu.FilterProvider>
FilterProvider(Base UIMenu.FilterProvider) wraps aContextor aSubmenuContext. The first menu inside it owns it, so a submenu stays unfiltered unless it has its own provider. Props:value/defaultValue(default'') /onValueChange(value, details): the query.detailshasreason('input-change','input-clear','clear-press','popup-close'),event, andcancel().autoHighlight:false(default),true(highlight the first match while the query isn't empty), or'always'(also when it is empty).filter(text, query): replaces the matching. It gets each item'slabel(or text) and the trimmed query. The default keeps items that contain the query, ignoring case and diacritics (the samecontainsText()combobox uses, exported withstartsWithText()).nullhides nothing, for filtering the rendered items yourself.locale: for the default matching.
input()(Base UIMenu.Input):type="text",role="searchbox",autocomplete="off",aria-controls(the list),aria-activedescendant(the highlighted item; items get anidwhen first highlighted if they have none),data-highlightedwhile focused and no item is highlighted by the keyboard. Itsvaluefollows the query.clear(options?)(Base UIMenu.Clear):type="button",tabIndex={-1},aria-hidden,hiddenwhile the query is empty,data-disabled. A press clears the query (reason'clear-press') and refocuses the input;mousedownis prevented so focus stays in the input. Option:disabled.empty()(Base UIMenu.Empty):role="status",aria-live="polite",aria-atomic,data-emptywhile no item of this menu is shown.EmptyTextrenders its children only then. Neither shows anything in server HTML.list()(Base UIMenu.List):role="menu", anid(default<popup id>-list),tabIndex={-1},aria-labelledby(the trigger).onMenuFilterChange(handler):MenuFilterChangeEvent(stems:menu-filter-change, from the input, bubbles) withvalueandreason.
Behavior:
- With a
FilterProvider, the trigger hasaria-haspopup="dialog"and the popuprole="dialog"(it holds the searchbox and the menu list), as in Base UI. - Items, submenu triggers, and groups whose items all fail to match get
hidden; they stay mounted. If your styles setdisplayon a group or item, add a[hidden]rule. - Opening with a click or the keyboard focuses the input; a keyboard open also highlights the first item. Opening on hover, touch, or pen focuses the popup instead, so no on-screen keyboard shows up.
- Focus stays in the input: ArrowDown / ArrowUp move the highlight (virtual focus, wrapping with
loopFocus); Enter activates the highlighted item. Home / End jump to the ends only while the input is empty and an item is highlighted, and ArrowLeft / ArrowRight move the caret unless the input is empty or the highlighted item opens a submenu (then they open / close it). Characters and modified keys edit the text: typeahead is off. Tab / Shift+Tab close the menu as usual. - Pointer highlights don't move focus out of the input.
- After the query changes, a highlighted item that no longer matches loses the highlight, and
autoHighlighthighlights the first match. - The query resets to
''(reason'popup-close') once the menu has finished closing. Cancel that change to keep it. - Labels come from the rendered text, so server HTML shows every item even with a
defaultValue; items are hidden once they mount in the browser.
Behavior notes
- Opening: a pointer press on the trigger focuses the popup with nothing highlighted; Enter / Space / ArrowDown focus the first enabled item, ArrowUp the last.
- Keyboard in the popup: ArrowDown / ArrowUp (ArrowRight / ArrowLeft when horizontal,
mirrored in RTL) move between enabled items, wrapping with
loopFocus; Home / End; printable keys typeahead (repeat a letter to cycle); Enter / Space activate (they call the item'sclick()); Escape closes the topmost menu; Tab closes the whole tree and moves focus past the trigger (Shift+Tab to the trigger). - Submenus: ArrowRight (ArrowLeft in RTL), Enter, or Space on a submenu trigger opens the
submenu and focuses its first item; ArrowLeft (ArrowRight in RTL) closes it and refocuses
the trigger. Hovering opens it after
delaywithout moving focus; the pointer can cross sibling items on its way to the submenu (hover aim). A press on the trigger opens it (and keeps a hover-opened submenu open). Highlighting another parent item closes it. Escape closes only the submenu unlesscloseParentOnEsc. Opening a submenu closes its open siblings; closing a menu closes its submenus. - Activation:
item()closes the whole tree by default (reason'item-press'); checkbox, radio and link items stay open unlesscloseOnClick. Focus returns to the trigger. - Highlight: items get real DOM focus.
data-highlightedfollows focus and pointer moves (not touch). When the pointer leaves an item for a non-item, the highlight clears and focus moves to the popup (a keyboard highlight is kept). - Dismissal: shared dismiss-layer stack. Pressing inside the parent closes an open
submenu; pressing outside closes the tree. With
modal(the default) everything outside the popup isinert, so pressing the trigger is an outside press. - Closed popups and unchecked indicators get
hiddenin server HTML;aria-checked,data-checked/data-unchecked, anddata-disabledare correct before hydration.
Styling hooks
| Part | Attribute / variable | Values / notes |
|---|---|---|
| trigger | data-popup-open, data-pressed | present while open |
| trigger | data-disabled | present when disabled |
| popup | data-open / data-closed | open state |
| popup | data-starting-style / data-ending-style | enter / exit transition |
| popup, arrow | data-side | top bottom left right inline-start inline-end (final side) |
| popup, arrow | data-align | start center end |
| popup | --anchor-width, --anchor-height | trigger size |
| popup | --available-width, --available-height | space to the viewport edges |
| popup | --transform-origin | for scale animations |
| arrow | data-uncentered, data-open / data-closed | |
| backdrop | data-open / data-closed, presence attrs | |
| item, link, sub-trigger | data-highlighted | present while highlighted |
| item, checkbox, radio, sub-trigger | data-disabled | present when disabled |
| checkbox / radio item | data-checked / data-unchecked | checked state (also aria-checked) |
| indicators | data-checked / data-unchecked, data-disabled, presence attrs | |
| radio group | data-disabled | present when disabled |
| sub-trigger | data-popup-open | present while its submenu is open |
| separator | data-orientation | horizontal / vertical |
| filter input | data-highlighted | focused, and no item is highlighted by the keyboard |
| filter clear | data-disabled, hidden | hidden while the query is empty |
| filter empty | data-empty | present while no item matches |
| item, sub-trigger, group | hidden | set by the filter for non-matching items |