Dropdown Menu
A generic dropdown menu — any trigger, structured rows (commands, checkboxes, radio groups, submenus), typeahead and clipping-aware placement.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/dropdown-menu.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DropdownMenu" component (lucide-react for
the check / chevron glyphs; no popover or floating-ui dependency).
Contract
- export const DropdownMenu = forwardRef<HTMLButtonElement, DropdownMenuProps>.
The ref, className and every remaining native prop land on the component's own
trigger <button>; the wrapper around it is a fixed `relative inline-flex` box.
- DropdownMenuProps: trigger: React.ReactNode (content rendered *inside* the
trigger button — text, an icon, an avatar; never an interactive element, that
would nest a button in a button); items: DropdownItem[]; side?: "top" |
"bottom" (default "bottom", preferred only — it flips); align?: "start" |
"center" | "end" (default "start", preferred only — it shifts); open?:
boolean; onOpenChange?: (open: boolean) => void; className?: string.
- Uncontrolled by default; passing `open` makes it controlled and the internal
state is never written (still call onOpenChange in both modes).
- DropdownItem is a discriminated union on `type`:
{ type: "item"; label; icon?; shortcut?; disabled?; destructive?; onSelect: ()
=> void }
{ type: "checkbox"; label; checked: boolean; onCheckedChange: (checked) =>
void }
{ type: "radio-group"; value: string; onValueChange: (value) => void; options:
{ label; value }[] }
{ type: "separator" }
{ type: "label"; label }
{ type: "submenu"; label; icon?; items: DropdownItem[] } ← recursive, any depth
- `shortcut` is a display-only <kbd> hint; the component never binds a global
key for it. `onSelect` / `onCheckedChange` / `onValueChange` are the only ways
anything happens — the menu owns no application state.
Behavior
- Opening: click toggles; ArrowDown opens with the first row focused, ArrowUp
with the last. Closing: Escape, Tab out, outside pointerdown, or activating a
command row — the first three of those restore focus to the trigger.
- Real DOM focus moves between rows (roving tabindex: the active row is
tabIndex 0, all others -1). Do NOT use aria-activedescendant: rows are real
<button>s so Enter/Space activation, disabled semantics and hit targets come
from the platform.
- Keyboard inside a menu: ArrowUp/ArrowDown wrap and skip disabled rows,
separators and labels; Home/End jump to the ends; Enter/Space activate the
focused row (native button activation — never swallow those keys);
Escape closes the current level.
- Typeahead: a printable character appends to a buffer that expires 300ms after
the last keystroke, then focus jumps to the next row whose label starts with
it. Repeating a single character ("ddd") cycles through every row starting
with "d" (search starts one row past the current one); a multi-character
buffer narrows as a prefix and searches from the current row. Space is
excluded — it belongs to the focused button.
- Submenus: ArrowRight (or Enter/click) on a submenu row opens it with its first
row focused; ArrowLeft or Escape closes it and returns focus to the parent
row. Hover opens after a ~120ms dwell so a pointer crossing the row on its way
somewhere else does not open it; hovering a *sibling* row schedules the open
submenu to close after the same dwell, and entering the submenu panel cancels
that pending close. Picking a leaf command closes every level at once.
- Command rows close the menu after onSelect. Checkbox and radio rows keep it
open — toggling three display options should take one trip, not three.
- Placement, and this is the part everything else gets wrong: measure against
the nearest *clipping ancestor* (walk up from the panel, first node whose
computed `overflow !== "visible"`, intersected with the viewport), not against
window.innerHeight. Cards, docs stages and sidebars set overflow:hidden all
the time, and a menu positioned against the viewport renders rows into that
clip — present in the DOM, impossible to click. From that box derive: flip to
the other side when the preferred one cannot fit and the other has more room;
shift along the cross axis so the panel stays inside; and cap `maxHeight` to
the room that actually exists so the row list scrolls internally. Submenus get
the same treatment on the horizontal axis (flip left when there is no room on
the right, slide up so the last row stays inside).
- Do all measuring inside a requestAnimationFrame callback (never synchronously
in an effect body), and re-run it on window resize and on capture-phase scroll
so an ancestor scrolling under an open menu re-caps it. Keep the panel at
opacity 0 until the first measurement lands — never `visibility: hidden`,
which silently drops the focus() that follows.
- Structure the panel as a shell (absolutely positioned, overflow visible) whose
children are the scrolling row list *and* the open submenu, as siblings. A
submenu rendered inside an `overflow-y: auto` list is clipped by it on both
axes, because CSS forces the other axis to `auto` as soon as one is not
`visible`.
- Height and width caps must be computed from numbers that do not depend on the
panel's own clamped size (content.scrollHeight, the clip's width) or the
measurement oscillates.
- Cleanup: the hover-intent timer, the typeahead timer, the outside-pointerdown
listener and the resize/scroll listeners all die with the level that owns
them. Closing unmounts the level, so one unmount cleanup covers both.
- Before restoring focus anywhere, check `node.isConnected` — a consumer can
swap `items` from inside onSelect and focus would otherwise land on <body>.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground panel, `border` +
shadow-md edge, bg-accent / text-accent-foreground for hover *and* focus (they
must look identical — pointer and keyboard highlight the same row),
text-muted-foreground for labels, shortcuts and the submenu chevron,
text-destructive + bg-destructive/10 for destructive rows, bg-border for the
separator. No hex, no rgb(), no palette classes.
- ARIA: trigger gets aria-haspopup="menu", aria-expanded, aria-controls; the
panel is role="menu" labelled by the trigger (a submenu is labelled by its
parent row); rows are role="menuitem" / role="menuitemcheckbox" +
aria-checked / role="menuitemradio" + aria-checked; radio options sit in a
role="group"; separators are role="separator"; a submenu row adds
aria-haspopup="menu" + aria-expanded + aria-controls.
- Section labels are role="presentation": a bare div between role="menu" and its
rows makes screen readers announce a menu that owns nothing.
- Every row reserves a size-4 leading slot (icon, check mark, radio dot, or
empty) so labels line up whatever the row type is; labels truncate, shortcuts
never wrap off the panel.
- Entrance keyframes ship inline via a React 19 hoisted <style href
precedence="medium"> tag (deduped across instances) and are wrapped in
motion-reduce:[animation:none].
Customization levers
- Density: the row's `px-2 py-1.5` and the panel's `p-1` are the whole density
story — shrink both together for a compact toolbar menu, grow them for a
touch surface.
- Panel sizing: MIN_PANEL_WIDTH / MAX_PANEL_WIDTH (192 / 288) and the
`min-w-48` on the shell decide how much a long label may stretch the menu
before it truncates.
- Timing: SUBMENU_INTENT_MS (120) is the hover dwell — raise it for dense menus
where the pointer crosses many rows, drop it to 0 for instant opening;
TYPEAHEAD_RESET_MS (300) is the typeahead window.
- Placement defaults: `side` / `align` are only preferences, so switching them
is safe — the flip/shift logic always wins over the request.
- Row vocabulary: the union is the extension point. Adding e.g.
{ type: "item-with-description" } means one more branch in the render pass and
one more line in countNavigable(); everything else (focus, typeahead, ARIA)
keeps working because it reads the DOM, not the props.
- Close-on-select policy: command rows call closeAll() in their onClick — move
that call into the checkbox/radio branches if your product wants a single-shot
menu instead of a sticky one.
- The trigger is deliberately unstyled beyond a neutral button skin: pass
`className` to turn it into a ghost icon button, an avatar chip, or a toolbar
segment.Concepts
- Clipping-aware placement — the panel is measured against the nearest ancestor whose computed
overflowis notvisible(intersected with the viewport), not against the window. That box decides the flip, the shift and themaxHeight; a menu inside a shortoverflow-hiddencard shrinks and scrolls instead of painting rows into a region nobody can click. - Roving tabindex — exactly one row is a tab stop (
tabIndex0) and real DOM focus moves between rows, so activation, disabled semantics and hit targets come from real buttons rather than fromaria-activedescendantbookkeeping. - Typeahead — printable keys accumulate into a buffer that expires 300ms after the last keystroke. A repeated single character cycles through rows starting with it; a multi-character buffer narrows as a prefix.
- Hover intent — a submenu opens only after the pointer dwells ~120ms on its row, and closes on the same dwell when a sibling row is hovered; entering the submenu panel cancels the pending close, so a diagonal mouse path does not slam it shut.
- Sibling submenu — the scrolling row list and the open submenu are siblings inside a positioning shell, because a submenu nested in an
overflow-y: autolist would be clipped by it on both axes. - Open-state ownership — command rows close the menu after
onSelect; checkbox and radio rows deliberately leave it open, so a view menu can be reconfigured in one visit.
Context Menu
A right-click / long-press menu that opens at the pointer — checkbox rows, submenus, typeahead, and flipping bounded by the nearest clipping ancestor.
Workspace Switcher
A workspace / organization switcher — active workspace in the trigger, grouped searchable menu in a portalled panel, pinned create + settings footer.