Navigation

Dropdown Menu

A generic dropdown menu — any trigger, structured rows (commands, checkboxes, radio groups, submenus), typeahead and clipping-aware placement.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Entrance keyframes ship with the component: React 19 hoists <style href> into
 * <head> and dedupes by href, so N menus on a page still emit one rule.
 */
const KEYFRAMES = `@keyframes zg-dropdown-in{from{opacity:0;transform:translateY(-4px) scale(0.98)}to{opacity:1;transform:none}}`

/** Hover dwell before a submenu opens (or before a sibling row closes it). */
const SUBMENU_INTENT_MS = 120

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/dropdown-menu.json

Prompt

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 overflow is not visible (intersected with the viewport), not against the window. That box decides the flip, the shift and the maxHeight; a menu inside a short overflow-hidden card shrinks and scrolls instead of painting rows into a region nobody can click.
  • Roving tabindex — exactly one row is a tab stop (tabIndex 0) and real DOM focus moves between rows, so activation, disabled semantics and hit targets come from real buttons rather than from aria-activedescendant bookkeeping.
  • 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: auto list 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.

On This Page