Navigation

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

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

100
150
Active edited usageDefault props
<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.json

Or, 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

PropTypeDefaultDescription
openboolean—Controlled open state; managed internally when omitted
defaultOpenbooleanfalseInitial open state in uncontrolled mode
onOpenChange(open: boolean) => void—Called when the open state changes
modalbooleantrueLocks page scroll while open; outside clicks only close the menu and never reach what is underneath
childrenReactNode—Trigger and Content
FieldTypeDefaultDescription
asChildbooleanfalseWhen true, renders no button and merges behavior and aria attributes into the single child element
classNamestring—Forwarded to the trigger; other button attributes (such as aria-label or disabled) are forwarded too
FieldTypeDefaultDescription
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
sideOffsetnumber4Distance from the trigger (px)
alignOffsetnumber0Extra offset along the cross axis (px)
collisionPaddingnumber8Minimum distance kept from the viewport edges (px)
durationnumber100Enter/exit animation duration (ms)
loopbooleantrueWhether arrow keys wrap around at the ends
subDelaynumber150How long the pointer must rest on a submenu item before it opens (ms); applies to all submenus
classNamestring—Forwarded to the menu surface
FieldTypeDefaultDescription
onSelect() => void—Called on selection (click, Enter or Space)
disabledbooleanfalseDisabled: cannot be selected and is skipped by keyboard navigation
insetbooleanfalseReserve left icon space to line up with checkbox items
variant"default" | "destructive""default"Visual variant; destructive is for delete, sign out and similar actions
textValuestring—Text used for typeahead matching; defaults to the item's text content
closeOnSelectbooleantrueWhether selecting closes the menu
classNamestring—Forwarded to the item
FieldTypeDefaultDescription
checkedboolean—Controlled checked state
defaultCheckedbooleanfalseInitial checked state in uncontrolled mode
onCheckedChange(checked: boolean) => void—Called when the checked state changes
onSelect() => void—Called on selection
disabledbooleanfalseDisabled
textValuestring—Text used for typeahead matching
closeOnSelectbooleanfalseWhether selecting closes the menu (checkbox items stay open by default)
classNamestring—Forwarded to the item
FieldTypeDefaultDescription
valuestring—Controlled selected value
defaultValuestring—Initial selected value in uncontrolled mode
onValueChange(value: string) => void—Called when the selected value changes
classNamestring—Forwarded to the group container
FieldTypeDefaultDescription
valuestring—The value this option represents (required)
onSelect() => void—Called on selection
disabledbooleanfalseDisabled
textValuestring—Text used for typeahead matching
closeOnSelectbooleanfalseWhether selecting closes the menu (radio items stay open by default)
classNamestring—Forwarded to the item
FieldTypeDefaultDescription
openboolean—Controlled submenu open state
defaultOpenbooleanfalseInitial open state in uncontrolled mode
onOpenChange(open: boolean) => void—Called when the submenu open state changes
FieldTypeDefaultDescription
disabledbooleanfalseDisabled
insetbooleanfalseReserve left icon space to line up with checkbox items
textValuestring—Text used for typeahead matching
classNamestring—Forwarded to the item
FieldTypeDefaultDescription
sideOffsetnumber-4Distance from the SubTrigger (px); negative values overlap the parent menu slightly
alignOffsetnumber-5Cross-axis offset (px); the default lines the first item up with the SubTrigger
collisionPaddingnumber8Minimum distance kept from the viewport edges (px)
loopbooleantrueWhether arrow keys wrap around at the ends
classNamestring—Forwarded to the submenu surface

Label / Group / Separator / Shortcut

FieldTypeDefaultDescription
Label.insetbooleanfalseGroup 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: fixed and portaled to body, 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 keeping collisionPadding. 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 as data-side / data-align.
  • Max height: the layer's max-height equals 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-origin follows the actual placement (e.g. top-left when it opens below and start-aligned). duration drives 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 subDelay ms. 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.
  • modal is on by default: while open, body scroll 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. With false, 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-expanded and aria-controls; the surface is role="menu" with aria-orientation="vertical" and aria-labelledby pointing back at the trigger
  • Items use menuitem, menuitemcheckbox or menuitemradio by type, and checkbox/radio items expose aria-checked; separators are role="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-disabled and are skipped by keyboard navigation; focus returns to the trigger after the menu closes
  • Typeahead ignores keystrokes during IME composition (isComposing or 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