# Dropdown Menu

> Menu button with button, icon, avatar or account-card triggers, an account header or filtering search, sections, checkbox and radio rows and submenus; it springs open from the trigger and a highlight glides between rows.

- Collection: Components
- Tier: free
- Docs: https://geturui.com/docs/dropdown
- Install: `npx shadcn@latest add @geturui/dropdown`
- Dependencies: motion, lucide-react

## Usage

```tsx
import { Copy, Pencil, Trash2 } from "lucide-react";
import { Dropdown } from "@/components/ui/dropdown";

export function RowActions() {
  return (
    <Dropdown
      trigger={{ kind: "icon", label: "Row actions" }}
      align="end"
      items={[
        { type: "label", label: "Component" },
        { label: "Rename", icon: <Pencil />, shortcut: "F2", onSelect: () => {} },
        { label: "Duplicate", icon: <Copy />, shortcut: "⌘D", onSelect: () => {} },
        { type: "separator" },
        { label: "Delete", icon: <Trash2 />, danger: true, onSelect: () => {} },
      ]}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| trigger | `{ kind: "button" | "icon"; label; icon?; variant?; size? } | { kind: "avatar"; name; initials? } | { kind: "account"; name; email? } | (props) => ReactNode` | — | Preset trigger, or a render function that spreads the given id, aria and handler props on your own element. |
| items | `DropdownEntry[]` | — | Rows { label, icon?, description?, shortcut?, badge?, onSelect?, disabled?, danger?, type?: "checkbox" | "radio", checked?, items?, keywords? }, { type: "separator" } or { type: "label", label } to start a section. |
| account | `{ name; email; initials?; badge? }` | — | Account card with initials avatar at the top of the menu. |
| search | `boolean | { placeholder?; emptyLabel? }` | — | Search box that filters rows by label, description and keywords. |
| header / footer | `ReactNode` | — | Extra content above the rows, and a non-interactive line under them (version, plan). |
| side / align / sideOffset | `"top" | "bottom" / "start" | "center" | "end" / number` | "bottom" / "start" / 6 | Preferred placement; it flips and shifts to stay on screen. |
| closeOnCheck | `boolean` | false | Whether checkbox and radio rows close the menu. |
| width | `number` | — | Menu width in px. Defaults to the content width, min 216. |
| open / defaultOpen / onOpenChange | `boolean / boolean / (open: boolean) => void` | — | Controlled or uncontrolled open state. |
| disabled | `boolean` | false | Disables the trigger. |
| menuClassName | `string` | — | Classes for every menu surface, including submenus. |

## Accessibility

- APG menu button: the trigger has aria-haspopup, aria-expanded and aria-controls; the menu is labelled by it. Enter, Space and Arrow Down open on the first row, Arrow Up on the last.
- Rows are menuitem, menuitemcheckbox or menuitemradio with aria-checked; sections are role="group" labelled by their heading. Up / Down wrap, Home / End jump, typing jumps to a matching row, Right opens and Left closes a submenu.
- Escape closes and returns focus to the trigger; Tab and outside clicks close without stealing focus. With search, focus starts in the box, arrows move into the list and the result count is announced.
- The menu is portalled, flips and shifts to stay in the viewport, and falls back to a plain fade with reduced motion.

## Source

### components/ui/dropdown.tsx

```tsx
"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, ChevronsUpDown, EllipsisVertical, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, type ButtonSize, type ButtonVariant } from "./button";
import { MenuPanel, filterMenuEntries, isMenuItem, type MenuEntry, type MenuItemEntry, type MenuPlacement } from "./menu-core";

export type DropdownEntry = MenuEntry;
export type DropdownItem = MenuItemEntry;

/** Props to spread on a custom trigger element. */
export interface DropdownTriggerProps {
  id: string;
  "aria-haspopup": "menu";
  "aria-expanded": boolean;
  "aria-controls"?: string;
  onClick: (e: React.MouseEvent<HTMLElement>) => void;
  onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void;
}

export type DropdownTrigger =
  | { kind: "button"; label: string; icon?: React.ReactNode; variant?: ButtonVariant; size?: ButtonSize }
  | { kind: "icon"; label: string; icon?: React.ReactNode; variant?: ButtonVariant; size?: ButtonSize }
  | { kind: "avatar"; name: string; initials?: string; label?: string; size?: "sm" | "md" | "lg" }
  | { kind: "account"; name: string; email?: string; initials?: string }
  | ((props: DropdownTriggerProps & { open: boolean }) => React.ReactNode);

export interface DropdownProps {
  /** A preset trigger (button, icon button, avatar, account card) or a render function that spreads the given props on your own element. */
  trigger: DropdownTrigger;
  items: DropdownEntry[];
  /** Account card at the top of the menu. */
  account?: { name: string; email: string; initials?: string; badge?: React.ReactNode };
  /** Search box that filters items by label, description and keywords. */
  search?: boolean | { placeholder?: string; emptyLabel?: string };
  /** Custom content above the items (after the account card / search). */
  header?: React.ReactNode;
  /** Non-interactive line under the items, e.g. a version or plan meter. Put actions in `items`. */
  footer?: React.ReactNode;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Preferred side; flips when the viewport has more room on the other side. */
  side?: "top" | "bottom";
  align?: "start" | "center" | "end";
  /** Gap between trigger and menu, px. */
  sideOffset?: number;
  /** Whether checkbox and radio rows close the menu. */
  closeOnCheck?: boolean;
  /** Menu width in px (default fits content, min 216). */
  width?: number;
  disabled?: boolean;
  /** Classes for the trigger wrapper. */
  className?: string;
  /** Classes for every menu surface, including submenus. */
  menuClassName?: string;
}

type FocusMode = "first" | "last" | "panel";

const noopSubscribe = () => () => {};
const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])';
const tints = ["bg-volt-soft text-volt-ink", "bg-info-soft text-info", "bg-ember-soft text-ember-ink", "bg-success-soft text-success", "bg-warning-soft text-warning"];

function initialsOf(name: string) {
  return name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((w) => w[0])
    .join("")
    .toUpperCase();
}

function Initials({ name, initials, className }: { name: string; initials?: string; className?: string }) {
  let h = 0;
  for (const c of name) h = (h + c.charCodeAt(0)) % 997;
  return (
    <span aria-hidden className={cn("grid shrink-0 place-items-center rounded-full font-semibold", tints[h % tints.length], className)}>
      {initials ?? initialsOf(name)}
    </span>
  );
}

const avatarSizes = { sm: "size-8 text-[11px]", md: "size-9 text-xs", lg: "size-10 text-[13px]" } as const;

function Chevron({ open, up }: { open: boolean; up?: boolean }) {
  const reduce = useReducedMotion();
  const Icon = up ? ChevronsUpDown : ChevronDown;
  return (
    <motion.span
      aria-hidden
      className="inline-flex"
      animate={{ rotate: open && !up ? 180 : 0 }}
      transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 26 }}
    >
      <Icon className="size-4 opacity-70" />
    </motion.span>
  );
}

function RenderTrigger({ render, ...props }: DropdownTriggerProps & { open: boolean; render: (p: DropdownTriggerProps & { open: boolean }) => React.ReactNode }) {
  return <>{render(props)}</>;
}

/**
 * Menu button: a trigger (button, icon, avatar, account card or your own) opens a menu that springs from the trigger,
 * flips to stay on screen and supports sections, icons, descriptions, shortcuts, badges, checkbox / radio rows,
 * submenus, an account header or a filtering search box. Full APG menu-button keyboard support.
 */
export function Dropdown({
  trigger,
  items,
  account,
  search,
  header,
  footer,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  side = "bottom",
  align = "start",
  sideOffset = 6,
  closeOnCheck = false,
  width,
  disabled,
  className,
  menuClassName,
}: DropdownProps) {
  const uid = React.useId();
  const triggerId = `${uid}-trigger`;
  const menuId = `${uid}-menu`;
  const [inner, setInner] = React.useState(defaultOpen);
  const isOpen = (openProp ?? inner) && !disabled;
  const [focusMode, setFocusMode] = React.useState<FocusMode>("panel");
  const [query, setQuery] = React.useState("");
  const [anchor, setAnchor] = React.useState<HTMLSpanElement | null>(null);
  const panelRef = React.useRef<HTMLDivElement>(null);
  const searchRef = React.useRef<HTMLInputElement>(null);
  const mounted = React.useSyncExternalStore(
    noopSubscribe,
    () => true,
    () => false,
  );
  const onOpenChangeRef = React.useRef(onOpenChange);
  React.useEffect(() => {
    onOpenChangeRef.current = onOpenChange;
  });

  const hasSearch = !!search;
  const searchOpts = search ? (typeof search === "object" ? search : {}) : null;
  const shown = React.useMemo(() => (hasSearch ? filterMenuEntries(items, query) : items), [items, query, hasSearch]);
  const resultCount = shown.filter(isMenuItem).length;
  const scrollable = !items.some((e) => isMenuItem(e) && e.items?.length);

  const placement = React.useMemo<MenuPlacement | null>(
    () => (anchor ? { kind: "anchor", anchor, side, align, offset: sideOffset } : null),
    [anchor, side, align, sideOffset],
  );

  const restoreFocus = React.useCallback(() => {
    requestAnimationFrame(() => anchor?.querySelector<HTMLElement>(FOCUSABLE)?.focus({ preventScroll: true }));
  }, [anchor]);

  const setOpen = React.useCallback(
    (next: boolean, opts: { focus?: FocusMode; restore?: boolean } = {}) => {
      if (next) {
        setFocusMode(opts.focus ?? "panel");
        setQuery("");
      }
      if (openProp === undefined) setInner(next);
      onOpenChangeRef.current?.(next);
      if (!next && opts.restore) restoreFocus();
    },
    [openProp, restoreFocus],
  );

  // Outside press closes without moving focus; the trigger itself toggles via its click handler.
  React.useEffect(() => {
    if (!isOpen) return;
    const onDown = (e: PointerEvent) => {
      const t = e.target as Node;
      if (panelRef.current?.contains(t) || anchor?.contains(t)) return;
      setOpen(false);
    };
    document.addEventListener("pointerdown", onDown, true);
    return () => document.removeEventListener("pointerdown", onDown, true);
  }, [isOpen, anchor, setOpen]);

  // With a search box, focus lands in it on open.
  React.useEffect(() => {
    if (!isOpen || !hasSearch) return;
    const t = requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
    return () => cancelAnimationFrame(t);
  }, [isOpen, hasSearch]);

  function focusRow(which: "first" | "last") {
    const rows = panelRef.current?.querySelectorAll<HTMLElement>('[role="menu"] > [data-menu-item]:not([aria-disabled]), [role="menu"] > [role="group"] > [data-menu-item]:not([aria-disabled])');
    if (!rows?.length) return;
    (which === "first" ? rows[0] : rows[rows.length - 1]).focus({ preventScroll: true });
  }

  const triggerProps: DropdownTriggerProps = {
    id: triggerId,
    "aria-haspopup": "menu",
    "aria-expanded": isOpen,
    "aria-controls": isOpen ? menuId : undefined,
    onClick: (e) => {
      if (disabled) return;
      if (isOpen) return setOpen(false);
      // detail 0 = keyboard click (Enter / Space): focus the first row, as the APG menu button asks.
      setOpen(true, { focus: e.detail === 0 ? "first" : "panel" });
    },
    onKeyDown: (e) => {
      if (disabled) return;
      if (e.key === "ArrowDown" || e.key === "ArrowUp") {
        e.preventDefault();
        setOpen(true, { focus: e.key === "ArrowDown" ? "first" : "last" });
      }
    },
  };

  let triggerNode: React.ReactNode;
  if (typeof trigger === "function") triggerNode = <RenderTrigger render={trigger} {...triggerProps} open={isOpen} />;
  else if (trigger.kind === "button")
    triggerNode = (
      <Button {...triggerProps} variant={trigger.variant ?? "secondary"} size={trigger.size ?? "md"} disabled={disabled}>
        {trigger.icon && <span className="inline-flex [&>svg]:size-4" aria-hidden>{trigger.icon}</span>}
        {trigger.label}
        <Chevron open={isOpen} />
      </Button>
    );
  else if (trigger.kind === "icon")
    triggerNode = (
      <Button {...triggerProps} aria-label={trigger.label} variant={trigger.variant ?? "ghost"} size={trigger.size ?? "md"} iconOnly disabled={disabled}>
        <span className="inline-flex [&>svg]:size-[18px]" aria-hidden>
          {trigger.icon ?? <EllipsisVertical />}
        </span>
      </Button>
    );
  else if (trigger.kind === "avatar")
    triggerNode = (
      <motion.button
        type="button"
        {...triggerProps}
        aria-label={trigger.label ?? `Account menu for ${trigger.name}`}
        disabled={disabled}
        whileTap={{ scale: 0.94 }}
        transition={{ type: "spring", stiffness: 520, damping: 26 }}
        className="rounded-full outline-none ring-2 ring-transparent transition-shadow hover:ring-border-strong focus-visible:shadow-[var(--focus-ring)] disabled:opacity-45"
      >
        <Initials name={trigger.name} initials={trigger.initials} className={avatarSizes[trigger.size ?? "md"]} />
      </motion.button>
    );
  else
    triggerNode = (
      <motion.button
        type="button"
        {...triggerProps}
        disabled={disabled}
        whileTap={{ scale: 0.98 }}
        transition={{ type: "spring", stiffness: 520, damping: 30 }}
        className={cn(
          "flex w-full min-w-0 items-center gap-2.5 rounded-md border border-border bg-surface p-2 pr-2.5 text-left outline-none transition-colors",
          "hover:border-border-strong hover:bg-surface-raised focus-visible:shadow-[var(--focus-ring)] disabled:opacity-45",
          isOpen && "border-border-strong bg-surface-raised",
        )}
      >
        <Initials name={trigger.name} initials={trigger.initials} className="size-9 text-xs" />
        <span className="min-w-0 flex-1">
          <span className="block truncate text-[13.5px] font-semibold leading-5 text-ink">{trigger.name}</span>
          {trigger.email && <span className="block truncate text-[12px] leading-4 text-ink-subtle">{trigger.email}</span>}
        </span>
        <span className="text-ink-subtle">
          <Chevron open={isOpen} up />
        </span>
      </motion.button>
    );

  const headerNode =
    account || searchOpts || header ? (
      <>
        {account && (
          <div className="-mx-1 -mt-1 mb-1 flex items-center gap-2.5 border-b border-border px-3.5 py-3">
            <Initials name={account.name} initials={account.initials} className="size-9 text-xs" />
            <div className="min-w-0 flex-1">
              <p className="truncate text-[13.5px] font-semibold leading-5">{account.name}</p>
              <p className="truncate text-[12px] leading-4 text-ink-subtle">{account.email}</p>
            </div>
            {account.badge}
          </div>
        )}
        {searchOpts && (
          <div className={cn("-mx-1 mb-1 border-b border-border px-1.5 pb-1.5", account ? "pt-0.5" : "-mt-1 pt-1.5")}>
            <label className="flex h-8 items-center gap-2 rounded-sm bg-surface-sunken px-2.5 text-ink-subtle focus-within:shadow-[0_0_0_2px_var(--volt-soft)]">
              <Search aria-hidden className="size-3.5 shrink-0" />
              <input
                ref={searchRef}
                type="search"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                aria-label={searchOpts.placeholder ?? "Search"}
                aria-controls={menuId}
                placeholder={searchOpts.placeholder ?? "Search"}
                autoComplete="off"
                spellCheck={false}
                onKeyDown={(e) => {
                  if (e.key === "ArrowDown" || e.key === "ArrowUp") {
                    e.preventDefault();
                    focusRow(e.key === "ArrowDown" ? "first" : "last");
                  } else if (e.key === "Enter") {
                    e.preventDefault();
                    panelRef.current?.querySelector<HTMLElement>("[data-menu-item]:not([aria-disabled])")?.click();
                  } else if (e.key === "Escape") {
                    e.preventDefault();
                    e.stopPropagation();
                    setOpen(false, { restore: true });
                  } else if (e.key === "Tab") {
                    setOpen(false);
                  }
                }}
                className="h-full min-w-0 flex-1 bg-transparent text-[13px] text-ink outline-none placeholder:text-ink-subtle [&::-webkit-search-cancel-button]:hidden"
              />
            </label>
            <span className="sr-only" aria-live="polite">
              {query ? `${resultCount} ${resultCount === 1 ? "result" : "results"}` : ""}
            </span>
          </div>
        )}
        {header}
      </>
    ) : null;

  return (
    <>
      <span ref={setAnchor} className={cn("inline-flex", typeof trigger !== "function" && trigger.kind === "account" && "flex w-full", className)}>
        {triggerNode}
      </span>
      {mounted &&
        placement &&
        createPortal(
          <AnimatePresence>
            {isOpen && (
              <MenuPanel
                key="menu"
                ref={panelRef}
                entries={shown}
                placement={placement}
                focusOnOpen={searchOpts ? "none" : focusMode}
                onCloseAll={(restore) => setOpen(false, { restore })}
                onExitTop={searchOpts ? () => searchRef.current?.focus({ preventScroll: true }) : undefined}
                onPrintableKey={
                  searchOpts
                    ? (key) => {
                        setQuery((q) => (key === "Backspace" ? q.slice(0, -1) : q + key));
                        searchRef.current?.focus({ preventScroll: true });
                      }
                    : undefined
                }
                closeOnCheck={closeOnCheck}
                header={headerNode}
                footer={footer ? <div className="-mx-1 -mb-1 mt-1 border-t border-border px-3.5 py-2.5 text-[12px] leading-4 text-ink-subtle">{footer}</div> : undefined}
                empty={
                  <div className="px-2.5 py-6 text-center text-[13px] text-ink-subtle">
                    {searchOpts?.emptyLabel ?? (
                      <>
                        No matches for <span className="text-ink-muted">“{query}”</span>
                      </>
                    )}
                  </div>
                }
                scrollable={scrollable}
                menuId={menuId}
                labelledBy={triggerId}
                menuClassName={menuClassName}
                width={width}
              />
            )}
          </AnimatePresence>,
          document.body,
        )}
    </>
  );
}

```

### components/ui/menu-core.tsx

```tsx
"use client";

/**
 * Shared menu engine for ContextMenu and Dropdown: entries, APG menu keyboard support (arrows, Home/End,
 * typeahead, submenus), checkbox and radio rows, sections, the gliding highlight and viewport-aware placement.
 * Internal module — import ContextMenu or Dropdown instead.
 */

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

export interface MenuItemEntry {
  /** "checkbox" and "radio" render menuitemcheckbox / menuitemradio rows. A plain item with `checked` set is a checkbox too. */
  type?: "item" | "checkbox" | "radio";
  label: string;
  icon?: React.ReactNode;
  /** Second line under the label. */
  description?: string;
  /** Shown right-aligned, e.g. "⌘D". Display only — bind the keys yourself. */
  shortcut?: string;
  /** Trailing node such as a Badge or a count. */
  badge?: React.ReactNode;
  onSelect?: () => void;
  disabled?: boolean;
  /** Destructive styling. */
  danger?: boolean;
  checked?: boolean;
  /** Nested entries open as a submenu beside the item. */
  items?: MenuEntry[];
  /** Extra words a search box matches against. */
  keywords?: string[];
  /** Override whether choosing this row closes the menu. */
  closeOnSelect?: boolean;
}

export type MenuEntry = MenuItemEntry | { type: "separator" } | { type: "label"; label: string };

export type MenuPlacement =
  | { kind: "point"; x: number; y: number }
  | { kind: "anchor"; anchor: HTMLElement; side: "top" | "bottom"; align: "start" | "center" | "end"; offset: number }
  | { kind: "sub"; flip: boolean };

export const isMenuItem = (e: MenuEntry): e is MenuItemEntry =>
  e.type === undefined || e.type === "item" || e.type === "checkbox" || e.type === "radio";

const SUB_WIDTH = 216;
const EDGE = 8;

function roleOf(e: MenuItemEntry) {
  if (e.type === "radio") return "menuitemradio" as const;
  if (e.type === "checkbox" || e.checked !== undefined) return "menuitemcheckbox" as const;
  return "menuitem" as const;
}

/** Filter entries by a query (label, description, keywords), keeping submenu parents with matching children and tidying separators and section labels. */
export function filterMenuEntries(entries: MenuEntry[], query: string): MenuEntry[] {
  const q = query.trim().toLowerCase();
  if (!q) return entries;
  const kept: MenuEntry[] = [];
  for (const e of entries) {
    if (!isMenuItem(e)) {
      kept.push(e);
      continue;
    }
    const hay = [e.label, e.description ?? "", ...(e.keywords ?? [])].join(" ").toLowerCase();
    if (hay.includes(q)) kept.push(e);
    else if (e.items?.length) {
      const sub = filterMenuEntries(e.items, q);
      if (sub.some(isMenuItem)) kept.push({ ...e, items: sub });
    }
  }
  // Drop section labels with no items after them, and leading / trailing / doubled separators.
  const out: MenuEntry[] = [];
  kept.forEach((e, i) => {
    if (e.type === "label") {
      const next = kept[i + 1];
      if (!next || !isMenuItem(next)) return;
    }
    if (e.type === "separator") {
      const prev = out[out.length - 1];
      const hasNext = kept.slice(i + 1).some(isMenuItem);
      if (!prev || prev.type === "separator" || !hasNext) return;
    }
    out.push(e);
  });
  return out;
}

export interface MenuPanelProps {
  entries: MenuEntry[];
  level?: number;
  placement: MenuPlacement;
  /** Where focus goes on mount: the first or last item (keyboard), the menu (pointer) or nowhere (hover-opened submenu). */
  focusOnOpen: "first" | "last" | "panel" | "none";
  /** Changing this re-applies focusOnOpen, e.g. ArrowRight on an item whose submenu is already open from hover. */
  focusToken?: number;
  onCloseAll: (restore: boolean) => void;
  /** Submenu only: close this level and return focus to the parent item. */
  onBack?: () => void;
  /** ArrowUp on the first row calls this instead of wrapping (e.g. back to a search box). */
  onExitTop?: () => void;
  /** Replaces typeahead: printable keys and Backspace are handed here (e.g. to a search box). */
  onPrintableKey?: (key: string) => void;
  /** Whether checkbox / radio rows close the menu when chosen. */
  closeOnCheck?: boolean;
  /** Root only: content above the list (account card, search box). */
  header?: React.ReactNode;
  /** Root only: content below the list. */
  footer?: React.ReactNode;
  /** Shown when there are no entries (e.g. no search results). */
  empty?: React.ReactNode;
  /** Cap the list height and scroll it. Leave off when rows have submenus. */
  scrollable?: boolean;
  menuId?: string;
  labelledBy?: string;
  menuClassName?: string;
  /** Extra classes for the root surface only. */
  className?: string;
  /** Root surface width in px (clamped to the viewport). */
  width?: number;
}

/** The menu surface: renders entries, owns keyboard navigation, the highlight and nested submenus. */
export function MenuPanel({
  entries,
  level = 0,
  placement,
  focusOnOpen,
  focusToken,
  onCloseAll,
  onBack,
  onExitTop,
  onPrintableKey,
  closeOnCheck = true,
  header,
  footer,
  empty,
  scrollable,
  menuId,
  labelledBy,
  menuClassName,
  className,
  width,
  ref: forwarded,
}: MenuPanelProps & { ref?: React.Ref<HTMLDivElement> }) {
  const reduce = useReducedMotion();
  const uid = React.useId();
  const surfaceRef = React.useRef<HTMLDivElement>(null);
  const listRef = React.useRef<HTMLDivElement>(null);
  const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
  const [active, setActive] = React.useState(-1);
  const [sub, setSub] = React.useState<{ index: number; keyboard: boolean; flip: boolean; token: number } | null>(null);
  const hoverTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  const typeahead = React.useRef<{ buffer: string; timer: ReturnType<typeof setTimeout> | null }>({ buffer: "", timer: null });
  const sideRef = React.useRef<"top" | "bottom" | null>(null);
  React.useImperativeHandle(forwarded, () => surfaceRef.current as HTMLDivElement);

  const enabled = entries.map((e, i) => (isMenuItem(e) && !e.disabled ? i : -1)).filter((i) => i !== -1);

  // Entries can change while open (search filter, toggled checkbox). Drop a highlight or submenu that no longer points at a live row.
  const activeEntry = entries[active];
  if (active !== -1 && !(activeEntry && isMenuItem(activeEntry) && !activeEntry.disabled)) setActive(-1);
  const subEntry = sub ? entries[sub.index] : undefined;
  if (sub && !(subEntry && isMenuItem(subEntry) && subEntry.items?.length)) setSub(null);

  // Placement: keep the root inside the viewport (flip at the pointer, or flip / shift around an anchor) and nudge submenus up if they overflow.
  React.useLayoutEffect(() => {
    const el = surfaceRef.current;
    if (!el) return;
    if (placement.kind === "point") {
      const r = el.getBoundingClientRect();
      const vw = window.innerWidth;
      const vh = window.innerHeight;
      const left = placement.x + r.width > vw - EDGE ? Math.max(EDGE, placement.x - r.width) : placement.x;
      const top = placement.y + r.height > vh - EDGE ? Math.max(EDGE, placement.y - r.height) : placement.y;
      el.style.left = `${left}px`;
      el.style.top = `${top}px`;
      el.style.transformOrigin = `${left < placement.x ? "100%" : "0%"} ${top < placement.y ? "100%" : "0%"}`;
      return;
    }
    if (placement.kind === "sub") {
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight;
      if (r.bottom > vh - EDGE) el.style.top = `${-4 - (r.bottom - (vh - EDGE))}px`;
      return;
    }
    const { anchor, align, offset } = placement;
    const place = () => {
      const a = anchor.getBoundingClientRect();
      const vw = window.innerWidth;
      const vh = window.innerHeight;
      // offsetWidth/Height ignore the entrance scale, so the first measurement is exact.
      const w = el.offsetWidth;
      const h = el.offsetHeight;
      const below = vh - a.bottom - offset - EDGE;
      const above = a.top - offset - EDGE;
      if (!sideRef.current) {
        sideRef.current =
          placement.side === "bottom" ? (h > below && above > below ? "top" : "bottom") : h > above && below > above ? "bottom" : "top";
      }
      const side = sideRef.current;
      const raw = align === "start" ? a.left : align === "end" ? a.right - w : a.left + a.width / 2 - w / 2;
      const left = Math.round(Math.min(Math.max(EDGE, raw), Math.max(EDGE, vw - w - EDGE)));
      el.style.left = `${left}px`;
      if (side === "bottom") {
        el.style.top = `${Math.round(a.bottom + offset)}px`;
        el.style.bottom = "auto";
      } else {
        el.style.top = "auto";
        el.style.bottom = `${Math.round(vh - a.top + offset)}px`;
      }
      // Room left for the scrolling list once the header / footer are accounted for.
      const chrome = h - (listRef.current?.offsetHeight ?? 0);
      el.style.setProperty("--menu-available-height", `${Math.max(140, Math.round((side === "bottom" ? below : above) - chrome))}px`);
      const ox = Math.round(Math.min(Math.max(a.left + a.width / 2 - left, 12), w - 12));
      el.style.transformOrigin = `${ox}px ${side === "bottom" ? "0%" : "100%"}`;
    };
    place();
    window.addEventListener("resize", place);
    window.addEventListener("scroll", place, true);
    return () => {
      window.removeEventListener("resize", place);
      window.removeEventListener("scroll", place, true);
    };
  }, [placement]);

  React.useEffect(() => {
    const t = requestAnimationFrame(() => {
      if (focusOnOpen === "first" && enabled.length) focusItem(enabled[0]);
      else if (focusOnOpen === "last" && enabled.length) focusItem(enabled[enabled.length - 1]);
      else if (focusOnOpen === "panel") listRef.current?.focus({ preventScroll: true });
    });
    return () => cancelAnimationFrame(t);
    // On mount, and again when the parent asks (focusToken).
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [focusToken]);

  React.useEffect(() => {
    const ta = typeahead.current;
    return () => {
      if (hoverTimer.current) clearTimeout(hoverTimer.current);
      if (ta.timer) clearTimeout(ta.timer);
    };
  }, []);

  function focusItem(i: number) {
    setActive(i);
    itemRefs.current[i]?.focus({ preventScroll: true });
  }

  function openSub(i: number, viaKeyboard: boolean) {
    const r = surfaceRef.current?.getBoundingClientRect();
    const f = r ? r.right + SUB_WIDTH > window.innerWidth - EDGE : false;
    setSub((prev) =>
      prev?.index === i
        ? { ...prev, keyboard: viaKeyboard || prev.keyboard, token: viaKeyboard ? prev.token + 1 : prev.token }
        : { index: i, keyboard: viaKeyboard, flip: f, token: 0 },
    );
  }

  function activate(i: number, viaKeyboard: boolean) {
    const e = entries[i];
    if (!e || !isMenuItem(e) || e.disabled) return;
    if (e.items?.length) return openSub(i, viaKeyboard);
    e.onSelect?.();
    const checkable = roleOf(e) !== "menuitem";
    if (e.closeOnSelect ?? (checkable ? closeOnCheck : true)) onCloseAll(true);
  }

  function runTypeahead(key: string) {
    const ta = typeahead.current;
    if (ta.timer) clearTimeout(ta.timer);
    ta.buffer += key.toLowerCase();
    ta.timer = setTimeout(() => {
      ta.buffer = "";
    }, 500);
    const pos = enabled.indexOf(active);
    // A single repeated letter cycles through matches; a longer buffer refines from the current row.
    const repeat = ta.buffer.length > 1 && ta.buffer.split("").every((c) => c === ta.buffer[0]);
    const needle = repeat ? ta.buffer[0] : ta.buffer;
    const start = needle.length === 1 ? pos + 1 : Math.max(pos, 0);
    for (let k = 0; k < enabled.length; k++) {
      const idx = enabled[(start + k) % enabled.length];
      const e = entries[idx];
      if (isMenuItem(e) && e.label.toLowerCase().startsWith(needle)) {
        focusItem(idx);
        return;
      }
    }
  }

  function onKeyDown(e: React.KeyboardEvent) {
    const pos = enabled.indexOf(active);
    switch (e.key) {
      case "ArrowDown":
        if (!enabled.length) return;
        focusItem(enabled[(pos + 1) % enabled.length]);
        break;
      case "ArrowUp":
        if (pos <= 0 && onExitTop) {
          setActive(-1);
          onExitTop();
          break;
        }
        if (!enabled.length) return;
        focusItem(enabled[pos <= 0 ? enabled.length - 1 : pos - 1]);
        break;
      case "Home":
        if (enabled.length) focusItem(enabled[0]);
        break;
      case "End":
        if (enabled.length) focusItem(enabled[enabled.length - 1]);
        break;
      case "ArrowRight": {
        const entry = entries[active];
        if (entry && isMenuItem(entry) && entry.items?.length) openSub(active, true);
        else return;
        break;
      }
      case "ArrowLeft":
        if (!onBack) return;
        onBack();
        break;
      case "Escape":
        if (onBack) onBack();
        else onCloseAll(true);
        break;
      case "Enter":
      case " ":
        if (active >= 0) activate(active, true);
        break;
      case "Tab":
        onCloseAll(false);
        break;
      default: {
        const printable = e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey;
        if (onPrintableKey && (printable || e.key === "Backspace")) onPrintableKey(e.key);
        else if (printable && e.key !== " ") runTypeahead(e.key);
        else return;
      }
    }
    e.preventDefault();
    e.stopPropagation();
  }

  let itemCount = 0;

  function renderItem(entry: MenuItemEntry, i: number) {
    const n = itemCount++;
    const on = active === i;
    const hasSub = !!entry.items?.length;
    const subOpen = sub?.index === i;
    const role = roleOf(entry);
    const checkable = role !== "menuitem";
    const indicator =
      role === "menuitemradio" ? (
        <span className={cn("grid size-3.5 place-items-center rounded-full border", entry.checked ? "border-volt-ink" : "border-border-strong")}>
          {entry.checked && <span className="size-1.5 rounded-full bg-volt-ink" />}
        </span>
      ) : entry.type === "checkbox" ? (
        <span
          className={cn(
            "grid size-3.5 place-items-center rounded-[4px] border",
            entry.checked ? "border-volt bg-volt text-on-volt" : "border-border-strong",
          )}
        >
          {entry.checked && <Check className="!size-2.5" strokeWidth={3.5} />}
        </span>
      ) : entry.checked ? (
        <Check className="text-volt-ink" strokeWidth={2.5} />
      ) : null;
    // Checkable rows with their own icon keep it and move the indicator to the end.
    const leading = checkable && !entry.icon ? indicator : entry.icon;
    const trailingIndicator = checkable && entry.icon ? indicator : null;
    return (
      <motion.div
        key={`${entry.label}-${i}`}
        ref={(el) => {
          itemRefs.current[i] = el;
        }}
        role={role}
        aria-checked={checkable ? !!entry.checked : undefined}
        aria-disabled={entry.disabled || undefined}
        aria-haspopup={hasSub ? "menu" : undefined}
        aria-expanded={hasSub ? subOpen : undefined}
        data-menu-item=""
        tabIndex={-1}
        initial={reduce ? false : { opacity: 0, y: -4 }}
        animate={{ opacity: entry.disabled ? 0.4 : 1, y: 0 }}
        transition={{ delay: reduce ? 0 : 0.02 + n * 0.018, duration: 0.18 }}
        onFocus={() => {
          if (!entry.disabled && active !== i) setActive(i);
        }}
        onPointerMove={() => {
          if (entry.disabled || active === i) return;
          focusItem(i);
          if (hoverTimer.current) clearTimeout(hoverTimer.current);
          hoverTimer.current = setTimeout(() => {
            if (hasSub) openSub(i, false);
            else setSub(null);
          }, 110);
        }}
        aria-label={entry.description ? undefined : entry.label}
        onClick={(e) => {
          e.stopPropagation();
          activate(i, false);
        }}
        className={cn(
          "relative flex cursor-default select-none gap-2.5 rounded-sm px-2.5 outline-none",
          entry.description ? "items-start py-2" : "h-8 items-center",
          entry.danger && !entry.disabled ? "text-danger" : "text-ink",
        )}
      >
        {(on || subOpen) && (
          <motion.span
            layoutId={`${uid}-highlight`}
            aria-hidden
            transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 40 }}
            className={cn("absolute inset-0 rounded-sm", entry.danger ? "bg-danger/12" : "bg-surface-hover")}
          />
        )}
        {(leading || !entry.description) && (
          <span
            aria-hidden
            className={cn(
              "relative grid shrink-0 place-items-center text-ink-muted [&>svg]:size-4",
              entry.description ? "mt-px min-h-4 min-w-4" : "size-4",
              entry.danger && "text-danger",
            )}
          >
            {leading}
          </span>
        )}
        {entry.description ? (
          <span className="relative min-w-0 flex-1">
            <span className={cn("block truncate font-medium leading-5", entry.danger && "text-danger")}>{entry.label}</span>
            <span className="block text-[12px] leading-4 text-ink-subtle">{entry.description}</span>
          </span>
        ) : (
          <span className={cn("relative flex-1 truncate", entry.danger && "text-danger")}>{entry.label}</span>
        )}
        {entry.badge && <span className={cn("relative flex shrink-0", entry.description && "mt-0.5")}>{entry.badge}</span>}
        {entry.shortcut && !hasSub && (
          <kbd className={cn("relative font-sans text-[11.5px] tracking-[0.5px] text-ink-subtle", entry.description && "mt-0.5")}>{entry.shortcut}</kbd>
        )}
        {trailingIndicator && (
          <span aria-hidden className="relative grid size-4 shrink-0 place-items-center [&>svg]:size-4">
            {trailingIndicator}
          </span>
        )}
        {hasSub && <ChevronRight aria-hidden className="relative size-3.5 text-ink-subtle" />}
        <AnimatePresence>
          {subOpen && entry.items && (
            <MenuPanel
              key="sub"
              entries={entry.items}
              level={level + 1}
              placement={{ kind: "sub", flip: sub.flip }}
              focusOnOpen={sub.keyboard ? "first" : "none"}
              focusToken={sub.token}
              onCloseAll={onCloseAll}
              closeOnCheck={closeOnCheck}
              onBack={() => {
                setSub(null);
                focusItem(i);
              }}
              menuClassName={menuClassName}
            />
          )}
        </AnimatePresence>
      </motion.div>
    );
  }

  // Rows after a section label are wrapped in role="group" labelled by it.
  const nodes: React.ReactNode[] = [];
  const open: { group: { key: string; labelId: string; children: React.ReactNode[] } | null } = { group: null };
  const flush = () => {
    const g = open.group;
    if (!g) return;
    nodes.push(
      <div key={g.key} role="group" aria-labelledby={g.labelId}>
        {g.children}
      </div>,
    );
    open.group = null;
  };
  for (let i = 0; i < entries.length; i++) {
    const entry = entries[i];
    if (entry.type === "separator") {
      flush();
      nodes.push(<div key={`sep-${i}`} role="separator" className="mx-2 my-1 h-px bg-border" />);
    } else if (entry.type === "label") {
      flush();
      const labelId = `${uid}-l${i}`;
      open.group = {
        key: `group-${i}`,
        labelId,
        children: [
          <div key="label" id={labelId} className="px-2.5 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-[0.5px] text-ink-subtle">
            {entry.label}
          </div>,
        ],
      };
    } else if (isMenuItem(entry)) {
      const node = renderItem(entry, i);
      if (open.group) open.group.children.push(node);
      else nodes.push(node);
    }
  }
  flush();

  const isRoot = placement.kind !== "sub";
  const flip = placement.kind === "sub" && placement.flip;

  return (
    <motion.div
      ref={surfaceRef}
      onPointerLeave={() => {
        if (!sub) setActive(-1);
      }}
      initial={
        reduce
          ? { opacity: 0 }
          : level === 0
            ? { opacity: 0, scale: placement.kind === "anchor" ? 0.94 : 0.96, filter: "blur(4px)" }
            : { opacity: 0, x: flip ? 8 : -8, filter: "blur(4px)" }
      }
      animate={{ opacity: 1, scale: 1, x: 0, filter: "blur(0px)" }}
      exit={
        reduce
          ? { opacity: 0, transition: { duration: 0.1 } }
          : level === 0
            ? { opacity: 0, scale: 0.97, filter: "blur(2px)", transition: { duration: 0.12 } }
            : { opacity: 0, x: flip ? 6 : -6, transition: { duration: 0.12 } }
      }
      transition={{ type: "spring", stiffness: 520, damping: 34, opacity: { duration: 0.14 } }}
      style={
        placement.kind === "point"
          ? { position: "fixed", left: placement.x, top: placement.y }
          : placement.kind === "anchor"
            ? { position: "fixed", left: 0, top: 0, ...(width ? { width, maxWidth: "calc(100vw - 16px)" } : null) }
            : { position: "absolute", top: -5, ...(flip ? { right: "calc(100% + 4px)" } : { left: "calc(100% + 4px)" }) }
      }
      className={cn(
        "z-[100] min-w-[216px] rounded-md border border-border bg-glass p-1 text-[13px] text-ink shadow-lg outline-none backdrop-blur-xl backdrop-saturate-150",
        isRoot && className,
        menuClassName,
      )}
    >
      {isRoot && header}
      <div
        ref={listRef}
        id={menuId}
        role="menu"
        aria-orientation="vertical"
        aria-labelledby={labelledBy}
        tabIndex={-1}
        onKeyDown={onKeyDown}
        className={cn("outline-none", scrollable && "max-h-[min(26rem,var(--menu-available-height,26rem))] overflow-y-auto overscroll-contain")}
      >
        {nodes}
        {!enabled.length && !entries.some(isMenuItem) && empty}
      </div>
      {isRoot && footer}
    </motion.div>
  );
}

```
