Dropdown Menu
A button-triggered dropdown menu with groups, separators, submenus, checkbox and radio items and shortcut hints, a sliding highlight, and full keyboard and typeahead support.
An action menu that opens from a button — a good fit for user menus, a row's "more" actions, or view toggles in a toolbar. It is a compound component: DropdownMenu.Trigger opens it and DropdownMenu.Content holds the items, which can be plain items, group labels, separators, submenus, checkbox and radio items, plus right-aligned shortcut hints. The layer is portaled to body and positioned against the trigger, flipping to the opposite side when it does not fit. A highlight slides between items, and arrow keys, Home/End, typeahead and submenu keyboard handling are all implemented in-house with no headless library.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add dropdown-menu.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<DropdownMenu/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/dropdown-menu.en.jsonOr, once registries are configured in components.json, install it as @webberui/dropdown-menu.
Usage
import { DropdownMenu } from "@/components/ui/dropdown-menu";
const [showStatusBar, setShowStatusBar] = React.useState(true);
const [panel, setPanel] = React.useState("bottom");
<DropdownMenu>
<DropdownMenu.Trigger>My account</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" className="w-56">
<DropdownMenu.Label>Yuqing Lin</DropdownMenu.Label>
<DropdownMenu.Item onSelect={() => console.log("Profile")}>
Profile
<DropdownMenu.Shortcut>⇧⌘P</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Invite members</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item>Email</DropdownMenu.Item>
<DropdownMenu.Item>Copy link</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem checked={showStatusBar} onCheckedChange={setShowStatusBar}>
Show status bar
</DropdownMenu.CheckboxItem>
<DropdownMenu.RadioGroup value={panel} onValueChange={setPanel}>
<DropdownMenu.RadioItem value="top">Top</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="bottom">Bottom</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive">Sign out</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state; managed internally when omitted |
defaultOpen | boolean | false | Initial open state in uncontrolled mode |
onOpenChange | (open: boolean) => void | — | Called when the open state changes |
modal | boolean | true | Locks page scroll while open; outside clicks only close the menu and never reach what is underneath |
children | ReactNode | — | Trigger and Content |
DropdownMenu.Trigger
| Field | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | When true, renders no button and merges behavior and aria attributes into the single child element |
className | string | — | Forwarded to the trigger; other button attributes (such as aria-label or disabled) are forwarded too |
DropdownMenu.Content
| Field | Type | Default | Description |
|---|---|---|---|
side | "top" | "right" | "bottom" | "left" | "bottom" | Side relative to the trigger; flips to the opposite side when it does not fit |
align | "start" | "center" | "end" | "start" | Alignment along the cross axis |
sideOffset | number | 4 | Distance from the trigger (px) |
alignOffset | number | 0 | Extra offset along the cross axis (px) |
collisionPadding | number | 8 | Minimum distance kept from the viewport edges (px) |
duration | number | 100 | Enter/exit animation duration (ms) |
loop | boolean | true | Whether arrow keys wrap around at the ends |
subDelay | number | 150 | How long the pointer must rest on a submenu item before it opens (ms); applies to all submenus |
className | string | — | Forwarded to the menu surface |
DropdownMenu.Item
| Field | Type | Default | Description |
|---|---|---|---|
onSelect | () => void | — | Called on selection (click, Enter or Space) |
disabled | boolean | false | Disabled: cannot be selected and is skipped by keyboard navigation |
inset | boolean | false | Reserve left icon space to line up with checkbox items |
variant | "default" | "destructive" | "default" | Visual variant; destructive is for delete, sign out and similar actions |
textValue | string | — | Text used for typeahead matching; defaults to the item's text content |
closeOnSelect | boolean | true | Whether selecting closes the menu |
className | string | — | Forwarded to the item |
DropdownMenu.CheckboxItem
| Field | Type | Default | Description |
|---|---|---|---|
checked | boolean | — | Controlled checked state |
defaultChecked | boolean | false | Initial checked state in uncontrolled mode |
onCheckedChange | (checked: boolean) => void | — | Called when the checked state changes |
onSelect | () => void | — | Called on selection |
disabled | boolean | false | Disabled |
textValue | string | — | Text used for typeahead matching |
closeOnSelect | boolean | false | Whether selecting closes the menu (checkbox items stay open by default) |
className | string | — | Forwarded to the item |
DropdownMenu.RadioGroup
| Field | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled selected value |
defaultValue | string | — | Initial selected value in uncontrolled mode |
onValueChange | (value: string) => void | — | Called when the selected value changes |
className | string | — | Forwarded to the group container |
DropdownMenu.RadioItem
| Field | Type | Default | Description |
|---|---|---|---|
value | string | — | The value this option represents (required) |
onSelect | () => void | — | Called on selection |
disabled | boolean | false | Disabled |
textValue | string | — | Text used for typeahead matching |
closeOnSelect | boolean | false | Whether selecting closes the menu (radio items stay open by default) |
className | string | — | Forwarded to the item |
DropdownMenu.Sub
| Field | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled submenu open state |
defaultOpen | boolean | false | Initial open state in uncontrolled mode |
onOpenChange | (open: boolean) => void | — | Called when the submenu open state changes |
DropdownMenu.SubTrigger
| Field | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Disabled |
inset | boolean | false | Reserve left icon space to line up with checkbox items |
textValue | string | — | Text used for typeahead matching |
className | string | — | Forwarded to the item |
DropdownMenu.SubContent
| Field | Type | Default | Description |
|---|---|---|---|
sideOffset | number | -4 | Distance from the SubTrigger (px); negative values overlap the parent menu slightly |
alignOffset | number | -5 | Cross-axis offset (px); the default lines the first item up with the SubTrigger |
collisionPadding | number | 8 | Minimum distance kept from the viewport edges (px) |
loop | boolean | true | Whether arrow keys wrap around at the ends |
className | string | — | Forwarded to the submenu surface |
Label / Group / Separator / Shortcut
| Field | Type | Default | Description |
|---|---|---|---|
Label.inset | boolean | false | Group heading (not interactive); inset lines it up with checkbox item text |
Group | — | — | Wraps a set of items in role="group" |
Separator | — | — | A thin role="separator" divider |
Shortcut | — | — | Right-aligned muted monospace hint; binding the actual key is up to you |
Every part accepts className, merged via cn().
How it works
- Positioning: the layer is
position: fixedand portaled tobody, placed against the trigger. When the main axis overflows and the opposite side has more room it flips; the cross axis is pushed back into the viewport while keepingcollisionPadding. Viewport resizes, scrolling in any ancestor, and size changes of the trigger or the layer all trigger a requestAnimationFrame-throttled recompute; the layer stays hidden until the first measurement, so it never flashes in the top-left corner. The actual placement after flipping is exposed asdata-side/data-align. - Max height: the layer's
max-heightequals the available viewport height in the placed direction, and long menus scroll internally. - Animation: it fades in, scales up from 95%, and slides 8px in from against its side;
transform-originfollows the actual placement (e.g. top-left when it opens below and start-aligned).durationdrives the timing and is also written to--wb-dropdown-menu-duration, which every CSS transition inside the layer reads, so you can override it globally. - Highlight: on hover or keyboard focus, the background slides between items with a shared layout animation.
- Submenus: open after resting for
subDelayms. When the pointer heads diagonally from a SubTrigger toward its submenu, a safe triangle (the exit point plus the submenu's two near corners) keeps the submenu open even if the pointer brushes over other items. - Typeahead: characters typed within 500ms build a prefix matched against
textValue(defaults to the item text, ignoring shortcuts); repeating one letter cycles through items with that initial, and CJK text matches on its first character. modalis on by default: while open,bodyscroll is locked (with scrollbar-width padding so the layout does not jump) and a transparent shield sits behind the menu so outside clicks only close it. Withfalse, the page keeps scrolling and an outside click closes the menu while the clicked element still responds normally.
Accessibility
- The trigger carries
aria-haspopup="menu",aria-expandedandaria-controls; the surface isrole="menu"witharia-orientation="vertical"andaria-labelledbypointing back at the trigger - Items use
menuitem,menuitemcheckboxormenuitemradioby type, and checkbox/radio items exposearia-checked; separators arerole="separator"and shortcut hints are hidden from assistive tech - Keyboard: on the trigger, Enter / Space / ↓ open and focus the first item and ↑ opens and focuses the last; inside the menu ↑↓ cycle through enabled items, Home / End jump to the ends, Enter / Space select, Escape closes, and Tab closes and lets focus move on naturally; → or Enter enters a submenu and ← or Escape goes back
- Disabled items carry
aria-disabledand are skipped by keyboard navigation; focus returns to the trigger after the menu closes - Typeahead ignores keystrokes during IME composition (
isComposingor keyCode 229), such as Chinese input - When the user has reduce motion enabled, the layer only fades in and out (same duration) with no scaling or sliding, and the highlight and radio dot snap into place
Fluid Tabs
Tabs whose indicator stretches like a liquid as it follows along, with directional content switching.
Pagination
A React pagination component whose current-page pill slides between numbers, with expandable ellipses that stagger in hidden pages, a compact “3 / 12” variant with rolling digits, arrow-key navigation and an SEO-friendly link mode.