bx

Dropdown Menu

Displays a menu to the user triggered by a button.

tsx
<DropdownMenu>
  <DropdownMenuTrigger for="menu">
    <Button variant="outline">Open</Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent id="menu">
    <DropdownMenuLabel>My Account</DropdownMenuLabel>
    <DropdownMenuSeparator />
    <DropdownMenuItem href="#">Profile</DropdownMenuItem>
    <DropdownMenuItem href="#">Billing</DropdownMenuItem>
    <DropdownMenuItem href="#">Team</DropdownMenuItem>
    <DropdownMenuSeparator />
    <DropdownMenuItem>Log out</DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

DropdownMenu is a classic dropdown menu opened on click (kebab menu, user actions, table row actions). It is based on the Popover pattern, with menu items.

Composition: trigger + content + items. Items can be <a href> or <div> elements with role="menuitem" and data-ui="close": activating one, with a click or with Enter, closes the menu. The ui.js island provides the keyboard: arrow keys, Home and End, type-ahead, Escape and Tab.

Installation

Terminal
$ bext ui add dropdown-menu

Usage

tsx
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, Button } from "@bext-stack/ui/primitives"

<DropdownMenu>
  <DropdownMenuTrigger for="user-menu">
    <Button variant="outline">Open</Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent id="user-menu" align="end">
    <DropdownMenuLabel>My Account</DropdownMenuLabel>
    <DropdownMenuSeparator />
    <DropdownMenuItem href="/profile">Profile</DropdownMenuItem>
    <DropdownMenuItem href="/settings">Settings</DropdownMenuItem>
    <DropdownMenuSeparator />
    <DropdownMenuItem>Log out</DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

Anatomy

<DropdownMenu>
  ├── <DropdownMenuTrigger for="menu-id">
  │   └── <Button>Open</Button>
  └── <DropdownMenuContent id="menu-id" align="start|end">
      ├── <DropdownMenuLabel>My Account</DropdownMenuLabel>
      ├── <DropdownMenuSeparator />
      ├── <DropdownMenuItem href="/profile">Profile</DropdownMenuItem>
      └── <DropdownMenuItem>Log out</DropdownMenuItem>

API Reference

Prop Type Default Description
className string — Wrapper relative inline-block.
Prop Type Default Description
for string — Matches the id of the Content.
Prop Type Default Description
id string — Matches Trigger.for.
align "start" | "end" "start" Horizontal alignment of the panel.
ariaLabel string — Accessible name (aria-label) of the menu. Without it the island names the menu after its trigger button (aria-labelledby).
className string — min-width:8rem by default, absolute top-full mt-2 border bg-popover p-1 shadow-md.
Prop Type Default Description
href string — If set, renders <a> and navigates. Otherwise a <div> that only closes the menu.
disabled boolean — Sets data-disabled and aria-disabled="true": no pointer events, 50% opacity, skipped by the arrow keys.
children ReactNode — Label + optional icons.
Prop Type Default Description
children ReactNode — Non-clickable header (section name, e.g. "My Account"). Not focusable.
Prop Type Default Description
className string — Extra classes on the thin bg-muted line (role="separator") between groups of items.

Data Attributes

Attributes you can target in custom CSS to override the style for each state.

Attribute On Values Description
data-ui="toggle-pop" Trigger data-target="#id" Clicking opens or closes.
data-overlay data-light Content hidden Light popover: an outside click closes it.
data-ui="close" Item — Closes on click and returns focus to the trigger.
data-state Trigger "open" | "closed" Set by the island, together with aria-haspopup="menu", aria-expanded and aria-controls on the button inside.
data-disabled Item present when disabled Removes pointer events, dims the item and takes it out of keyboard navigation.

Keyboard

Key Action
Enter / Space On the trigger button: opens the menu and focuses the first item. On an item: activates it, closes the menu and returns focus to the trigger.
ArrowDown / ArrowUp On the trigger: opens the menu on the first or the last item. In the menu: moves to the next or previous item, wrapping at the ends. Disabled items are skipped.
Home / End Moves to the first or the last item.
A letter Moves to the next item whose label starts with that letter.
Escape Closes the menu and returns focus to the trigger.
Tab Closes the menu and moves on to the next element after the trigger.

Accessibility

DropdownMenuTrigger is a <span> around your button. The island sets aria-haspopup="menu", aria-controls and aria-expanded on that button and keeps aria-expanded in sync on every open and close, including a close by outside click or Escape. Put a real <button> inside the trigger.

DropdownMenuContent has role="menu" and is named by its trigger button (aria-labelledby) or by the ariaLabel prop. Every DropdownMenuItem has role="menuitem" and tabindex="-1": items are reached with the arrow keys, not with Tab. DropdownMenuSeparator sets role="separator". DropdownMenuLabel is plain text and is never focused.

Focus: opened from the keyboard, the menu focuses its first item. Opened with the pointer, the menu itself takes focus, so the arrow keys work without highlighting an item the user did not point at; hovering an item focuses it. Closing with Escape or by activating an item returns focus to the trigger.

Examples

Aligned end (kebab menu)

tsx
<div className="flex justify-end w-full">
  <DropdownMenu>
    <DropdownMenuTrigger for="row-actions">
      <Button variant="ghost" className="h-8 w-8 p-0" aria-label="Open menu"><MoreVerticalIcon /></Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent id="row-actions" align="end">
      <DropdownMenuItem>Edit</DropdownMenuItem>
      <DropdownMenuItem>Duplicate</DropdownMenuItem>
      <DropdownMenuSeparator />
      <DropdownMenuItem>Delete</DropdownMenuItem>
    </DropdownMenuContent>
  </DropdownMenu>
</div>
Try it live
Open in play

Edit this component live in the bext playground. The PRISM + signals version is the bext-native idiom: fine-grained reactivity, no virtual DOM.

src/app/page.tsx 2 files · signals · runs in your browser
Pure PRISM + real bext signals: fine-grained, no re-render.