# Dropdown

A shadcn-based action menu with a concise data API, advanced composition, and smooth Motion transitions.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```tsx
"use client";

import { useState } from "react";
import { Dropdown } from "@/components/ui/dropdown";

export const DropdownDemo = () => {
  const [selected, setSelected] = useState("None");
  return (
    <div className="flex flex-col items-center gap-4">
      <Dropdown
        animated={true}
        disabled={false}
        contentProps={{ side: "bottom", align: "start" }}
        trigger={<button type="button" className="rounded-md border border-input bg-background px-3 py-2 text-sm font-medium shadow-xs hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 disabled:opacity-50">{"Actions"}</button>}
        items={[
          { id: "edit", label: "Edit", shortcut: "⌘E", onSelect: () => setSelected("Edit") },
          { id: "duplicate", label: "Duplicate", onSelect: () => setSelected("Duplicate") },
          { id: "share", type: "submenu", label: "Share", items: [
            { id: "team", label: "Team", onSelect: () => setSelected("Team") },
            { id: "public", label: "Public link", disabled: true },
          ] },
          { id: "separator", type: "separator" },
          { id: "delete", label: "Delete", variant: "destructive", onSelect: () => setSelected("Delete") },
        ]}
      />
      <p role="status" className="text-sm text-muted-foreground">Last action: {selected}</p>
    </div>
  );
};
```

## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/dropdown.json
```

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install @base-ui/react@^1.8.0 cn lucide-react motion@^12.38.0
```

## Usage [#usage]

```tsx
"use client";

import { Dropdown } from "@/components/ui/dropdown";

export const Actions = () => (
  <Dropdown
    trigger={<button type="button">Actions</button>}
    items={[
      { id: "print", label: "Print", onSelect: () => window.print() },
      { id: "divider", type: "separator" },
      { id: "delete", label: "Delete", variant: "destructive", disabled: true },
    ]}
  />
);
```

Use Dropdown for **actions**, not a form value selector. For selecting a value, use [Select](/docs/components/select). `trigger` is a single React element; Base UI merges trigger props and refs onto it without adding a nested button. Custom triggers must forward refs and native props. Use `triggerProps={{ nativeButton: false }}` for non-button elements; provide the appropriate semantics yourself.

## Examples [#examples]

### Actions and shortcut hints [#actions-and-shortcut-hints]

The demo only updates its status; Delete does not remove data. `icon` accepts an SVG component such as a Lucide icon. `shortcut` displays a hint, but does not register a keyboard command.

<ComponentPreview name="dropdown-demo" title="dropdown-demo.tsx">
  <DropdownDemo />
</ComponentPreview>

### Groups, submenus, and controlled visibility [#groups-submenus-and-controlled-visibility]

Entries use stable `id` keys unique among siblings. Action entries have optional `type: "item"`; separators use `type: "separator"`; groups and submenus use `type: "group"` or `type: "submenu"` plus their own `items`. Group labels are optional. Nested entries preserve their order. An empty array renders an empty menu; provide a disabled informational action when useful.

<ComponentPreview name="dropdown-nested-demo" title="dropdown-nested-demo.tsx">
  <DropdownNestedDemo />
</ComponentPreview>

`open` with `onOpenChange(open, details)` is controlled; `defaultOpen` initializes uncontrolled visibility. `details.cancel()` rejects an interaction request. Externally changing `open` also works. `onOpenChangeComplete` fires after each completed transition; interrupted closing does not report a completed close. `disabled` disables the root and its trigger.

### Compound settings and links [#compound-settings-and-links]

Compose `DropdownRoot`, `DropdownTrigger`, and `DropdownContent` for custom content. Add groups/labels, items, links, checkbox/radio items, separators, and nested `DropdownSub` / `DropdownSubTrigger` / `DropdownSubContent` as needed. Put `DropdownLabel` inside a `DropdownGroup`. Compound parts inherit their corresponding [Base UI Menu API](https://base-ui.com/react/components/menu).

<ComponentPreview name="dropdown-settings-demo" title="dropdown-settings-demo.tsx">
  <DropdownSettingsDemo />
</ComponentPreview>

Checkbox items use `checked` / `defaultChecked` and `onCheckedChange`; radio groups use `value` / `defaultValue` and `onValueChange`, with `value` on each radio item. Checkbox items stay open by default; radio items close by default. Set `closeOnClick={false}` to keep radio settings open. Use `DropdownLinkItem` for navigation rather than an action callback; it stays open by default, with `closeOnClick` available.

### Router links and component-based overlays [#router-links-and-component-based-overlays]

Pass a router element through `link`, for example `link: <Link href="/settings" />` with Next.js or `link: <Link to="/projects/$projectId" params={{ projectId }} />` with TanStack Router. Router-specific props are checked where that JSX is created; Dropdown has no router dependency. The element must render one anchor and forward its props and ref. The entry's label, icon, and shortcut supply the link contents. Compact links close by default; set `closeOnSelect: false` to retain the menu. Native link modifiers and router handlers stay on the anchor. Use `event.preventBaseUIHandler()` in a composed handler when you also want to cancel Base UI dismissal; `preventDefault()` alone can be used by routers to intercept native navigation.

`renderOverlay` receives controlled `open`, `onOpenChange`, and `finalFocus` props. Return your own Dialog/Drawer wrapper; forward `finalFocus` into its `contentProps`. Install Dialog or Drawer separately when using them. Dropdown does not install either component or require an overlay provider.

<ComponentPreview name="dropdown-integrations-demo" title="dropdown-integrations-demo.tsx">
  <DropdownIntegrationsDemo />
</ComponentPreview>

Overlays are rendered as siblings of the menu root, outside the popup subtree, including while closed. Keep hooks inside the returned component, not inside the render callback. Selection requests menu dismissal first; the overlay opens on the menu's completed-close callback, without a fixed timeout. A canceled close or interrupted reopen does not open the queued overlay. Controlled owners must accept the close request for the handoff to complete. Keep entry ids stable and do not remove the overlay entry while it is open. Overlay wrappers must honor the supplied controlled state, provide a title, and forward focus props.

Each compact entry has only one intent: `onSelect`, `link`, or `renderOverlay`. Link entries do not accept `disabled` because Base UI LinkItem has no disabled contract; omit unavailable links or represent them as disabled action entries instead. Overlay entries always request closing and do not accept `closeOnSelect` or async callbacks.

### Async actions and failure handling [#async-actions-and-failure-handling]

Returning a Promise from an action enables automatic pending state, a loading indicator, and `aria-busy`. Other actions, links, and submenu triggers cannot activate while that action is pending. `closeOnSelect: "success"` keeps the menu open until the callback resolves; rejection resets pending and invokes `onSelectError(error)` without automatically closing. Synchronous exceptions are also reported through `onSelectError`. Without an error handler, errors are logged to the console; Dropdown does not invent a toast or expose sensitive errors in the UI.

<ComponentPreview name="dropdown-async-demo" title="dropdown-async-demo.tsx">
  <DropdownAsyncDemo />
</ComponentPreview>

This example simulates a request and a failure. It does not invalidate a session or redirect. Escape and outside dismissal remain available while pending and do not cancel the operation. Reopening the menu retains the pending lock; successful completion of an older dismissed request does not close a newly opened menu. Removing the entire Dropdown does not abort application work, but its UI no longer updates or reports errors through its callback after unmount.

For real Logout, await server-side session invalidation before clearing client state and redirecting. Use TanStack Query's `mutateAsync()`, not `mutate()`, to return a Promise. With `fetch()`, check `response.ok` and throw on HTTP failures. The following is an application-integration fragment; `logoutMutation`, `router`, and `toast` are supplied by your app:

```tsx
{
  id: "logout",
  label: "Logout",
  variant: "destructive",
  closeOnSelect: "success",
  onSelect: async () => {
    await logoutMutation.mutateAsync();
    await router.navigate({ to: "/login" });
  },
  onSelectError: () => toast.error("Logout failed. Try again."),
}
```

### Consumer setup and optional stories [#consumer-setup-and-optional-stories]

Requires React 19, Tailwind CSS 4 with shadcn semantic theme tokens, `@base-ui/react@^1.8.0`, `motion@^12.38.0`, `cn`, and `lucide-react`. No animation CSS or extra provider is required. The `new-york` source directory uses the project's shadcn **Base Nova** foundation, not Radix. Dropdown does not install Button or overwrite the website's internal dropdown-menu. Use your own styled trigger or install Button separately.

The component follows your configured UI alias. Review the CLI overwrite prompt if `dropdown.tsx` already exists. For manual installation, install the generated Dependencies and copy the complete Source into your configured UI directory. Examples and playground adapters are website-only.

Install Dropdown first, then optionally add stories:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/dropdown-stories.json
```

Use your existing React Storybook setup with matching `@storybook/react` package versions (checked with 10.6.1). Discover `**/*.stories.tsx` in your UI directory and load your global Tailwind/theme stylesheet in the preview. No extra addons/providers are required. The stories item installs no component, runtime dependencies, or Storybook configuration. This project has no Storybook server; Storybook UI is not verified here.

## Behavior & Accessibility [#behavior--accessibility]

Base UI owns focus management, arrow-key navigation, Home/End, typeahead, Enter/Space activation, Escape dismissal, outside interaction, and collision-aware positioning. Disabled items remain keyboard-discoverable but cannot activate. Submenu Escape closes only that submenu by default. Main-menu dismissal restores focus according to the primitive's `finalFocus` policy. Supply a meaningful `aria-label` for icon-only triggers. Decorative icons are hidden from assistive technology. Long action labels wrap, and content scrolls within available space.

Action `onSelect` receives the Base UI Item React click event; it is not a menu-wide value callback. Actions close immediately by default (`closeOnSelect: true`), including Promise-returning actions. Use `false` to disable automatic closing, or `"success"` to close only after success. Synchronous `preventDefault()` or `preventBaseUIHandler()` also cancels automatic action dismissal. Compound Item uses Base UI `onClick` and `closeOnClick` instead and does not implement the compact API's async orchestration. API calls, confirmation, session invalidation, redirect, and user-facing errors remain application responsibilities. Destructive styling is not a confirmation step.

Motion React's native animations fade and scale Content in 180ms and out in 120ms. Base UI observes those animations to coordinate unmounting, focus return, and rapid reopening. Reduced-motion preferences or `animated={false}` skip transitions. The compact `animated` prop applies to submenus too; compound menus configure each Content separately. `keepMounted` is false by default; when enabled, Base UI keeps closed content hidden and non-interactive.

Placement belongs to `contentProps` (or Content): `side`, `align`, `sideOffset`, `alignOffset`, `collisionPadding`, `collisionAvoidance`, and `anchor`. Root defaults to a modal menu. Reset restores playground configuration, selection, and visibility; generated code reproduces configured behavior, not the current selected action.

## Props [#props]

| Prop          | Type                                                                     | Default       | Description                                                                                                                                                                                                                                                                                             |
| ------------- | ------------------------------------------------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| align         | "start" \| "center" \| "end"                                             | "start"       | contentProps / Content: alignment against the trigger.                                                                                                                                                                                                                                                  |
| animated      | boolean                                                                  | true          | Dropdown / Content: native Motion fade and scale. Reduced motion skips animation automatically; compact API applies to submenus too.                                                                                                                                                                    |
| checked       | boolean                                                                  | Uncontrolled  | CheckboxItem: checked/defaultChecked and onCheckedChange(checked, details). Does not close by default; closeOnClick overrides this. RadioGroup uses value/defaultValue/onValueChange; RadioItem requires value and closes by default.                                                                   |
| closeOnSelect | boolean \| "success"                                                     | true          | Compact action: true closes immediately, false never closes automatically, success closes after callback resolution. Rejection keeps the current visibility. Compact links accept only boolean; overlays always request closing. Compound parts use closeOnClick instead.                               |
| composition   | Base UI Menu props                                                       | Not set       | Compound parts inherit their Base UI props and refs: Root, Trigger, Content, Group, Label, Item, LinkItem, Separator, Shortcut, Sub, SubTrigger, SubContent, CheckboxItem, RadioGroup, RadioItem. Label/SubTrigger/checkbox/radio support inset. Shortcut accepts span props. Place Label inside Group. |
| contentProps  | DropdownContent props                                                    | Not set       | Dropdown: Content props except children/animated; includes ref, className, render and placement. Content defaults sideOffset=4, alignOffset=0, collisionPadding=8, keepMounted=false; SubContent defaults inline-end, 0, -3.                                                                            |
| disabled      | boolean                                                                  | false         | Root / Dropdown: disables the entire menu. triggerProps.disabled can disable only its trigger.                                                                                                                                                                                                          |
| items         | readonly DropdownEntry\[]                                                | Required      | Dropdown: readonly entries with stable unique sibling ids. Choose one intent per item: action, JSX link, or renderOverlay. Actions have label, icon, shortcut, disabled and variant. Groups/submenus have items; separators only need id and type.                                                      |
| modal         | boolean                                                                  | true          | Root / Dropdown: modal menu behavior inherited from Base UI.                                                                                                                                                                                                                                            |
| link          | ReactElement                                                             | Not set       | Compact link entry: router JSX or an anchor, composed through LinkItem without nested elements. Forwards router props, handlers and refs. Entry label supplies children. No disabled/variant/action/overlay props.                                                                                      |
| onSelect      | (event) => unknown                                                       | Not set       | Compact action: receives the Base UI Item click event. A returned Promise automatically locks all compact items and shows pending until settled, even after dismissal/reopen. No request cancellation is implied.                                                                                       |
| onSelectError | (error: unknown) => void                                                 | Console error | Compact action: handles synchronous exceptions or Promise rejection. Provide application feedback such as a toast. Not called after Dropdown unmounts.                                                                                                                                                  |
| onOpenChange  | (open, details) => void                                                  | Not set       | Root / Dropdown: receives (open, details). details.cancel() rejects a request; externally changing open also works. onOpenChangeComplete fires after each completed transition.                                                                                                                         |
| open          | boolean                                                                  | Uncontrolled  | Root / Dropdown: controlled visibility. Pair with onOpenChange; defaultOpen initializes uncontrolled visibility (false).                                                                                                                                                                                |
| renderOverlay | (props: DropdownOverlayProps) => ReactNode                               | Not set       | Compact overlay entry: returns a controlled component rendered outside the menu root. Receives open, onOpenChange and finalFocus (trigger ref). Forward finalFocus to Dialog/Drawer contentProps. Opens after accepted menu close completes; do not call hooks in this callback.                        |
| showShortcuts | boolean (demo)                                                           | true (demo)   | Demo only: displays shortcut hints. Shortcuts do not register keyboard commands.                                                                                                                                                                                                                        |
| showSubmenu   | boolean (demo)                                                           | true (demo)   | Demo only: adds a nested Share menu to the items array.                                                                                                                                                                                                                                                 |
| side          | "top" \| "bottom" \| "left" \| "right" \| "inline-start" \| "inline-end" | "bottom"      | contentProps / Content: preferred side; collision handling may flip it. Also supports inline-start / inline-end.                                                                                                                                                                                        |
| trigger       | ReactElement                                                             | Required      | Dropdown: one React element. Props and ref are merged onto it; use a native button or a ref-forwarding button component.                                                                                                                                                                                |
| triggerProps  | DropdownTrigger props                                                    | Not set       | Dropdown: Base UI Trigger props except children/render, including ref, nativeButton, aria-label and event handlers. Use nativeButton=false for non-button render elements.                                                                                                                              |
| variant       | "default" \| "destructive"                                               | "default"     | Item / action entry: default or destructive styling. Item also accepts inset (false), label, disabled and Base UI Item props. Action onSelect maps to onClick; closeOnSelect defaults true.                                                                                                             |

The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.

## Source [#source]

```tsx
"use client";

/*
 * Adapted from shadcn/ui Base Nova dropdown-menu.
 * MIT License — Copyright (c) 2026 Shadcn Labs
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

import { Menu as Primitive } from "@base-ui/react/menu";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "cn";
import { CheckIcon, ChevronRightIcon, LoaderCircleIcon } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useAnimate as useAnimateMini } from "motion/react-mini";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type {
  ComponentProps,
  ComponentType,
  ReactElement,
  ReactNode,
  RefObject,
  SVGProps,
} from "react";

export const DropdownRoot = Primitive.Root;
export const DropdownSub = Primitive.SubmenuRoot;
export const DropdownGroup = Primitive.Group;
export const DropdownRadioGroup = Primitive.RadioGroup;

export const DropdownTrigger = (
  props: ComponentProps<typeof Primitive.Trigger>
) => <Primitive.Trigger data-slot="dropdown-trigger" {...props} />;

// Motion's native animations are visible to Base UI's animation lifecycle.
// Base UI owns unmounting, completion callbacks, focus return and interrupted exits.
const AnimatedPopup = ({
  nativeProps,
  state,
  render,
  animated,
}: {
  nativeProps: ComponentProps<"div">;
  state: Primitive.Popup.State;
  render?: Primitive.Popup.Props["render"];
  animated: boolean;
}) => {
  const [scope, animate] = useAnimateMini<HTMLDivElement>();
  const reduceMotion = useReducedMotion();
  useLayoutEffect(() => {
    const element = scope.current;
    if (!element) {
      return;
    }
    const opacity = state.open ? 1 : 0;
    const scale = state.open ? 1 : 0.97;
    if (!animated || reduceMotion || typeof element.animate !== "function") {
      element.style.opacity = String(opacity);
      element.style.scale = "1";
      return;
    }
    const animation = animate(
      [element],
      { opacity, scale },
      {
        duration: state.open ? 0.18 : 0.12,
        ease: [0.22, 1, 0.36, 1],
      }
    );
    for (const nativeAnimation of element.getAnimations()) {
      // Interrupted animations reject finished; rapid reopen is expected.
      // eslint-disable-next-line promise/prefer-await-to-then
      nativeAnimation.finished.catch(() => {
        // Cancellation is expected when a transition is interrupted.
      });
    }
    return () => animation.stop();
  }, [animate, animated, reduceMotion, scope, state.open]);
  return useRender({
    props: { ...nativeProps },
    ref: [nativeProps.ref ?? null, scope],
    render,
    state: { ...state },
  });
};

export type DropdownContentProps = Primitive.Popup.Props &
  Pick<
    Primitive.Positioner.Props,
    | "side"
    | "sideOffset"
    | "align"
    | "alignOffset"
    | "collisionPadding"
    | "collisionAvoidance"
    | "anchor"
  > & {
    animated?: boolean;
    keepMounted?: boolean;
  };

export const DropdownContent = ({
  align = "start",
  alignOffset = 0,
  side = "bottom",
  sideOffset = 4,
  collisionPadding = 8,
  collisionAvoidance,
  anchor,
  animated = true,
  keepMounted = false,
  className,
  render,
  ...props
}: DropdownContentProps) => (
  <Primitive.Portal
    keepMounted={keepMounted}
    style={{ left: 0, position: "absolute", top: 0 }}
  >
    <Primitive.Positioner
      align={align}
      alignOffset={alignOffset}
      side={side}
      sideOffset={sideOffset}
      collisionPadding={collisionPadding}
      collisionAvoidance={collisionAvoidance}
      anchor={anchor}
      className="isolate z-50 outline-none"
    >
      <Primitive.Popup
        data-slot="dropdown-content"
        className={cn(
          "max-h-(--available-height) max-w-[min(var(--available-width),calc(100vw-1rem))] min-w-40 origin-(--transform-origin) scale-[0.97] overflow-x-hidden overflow-y-auto overscroll-contain rounded-lg bg-popover p-1 text-popover-foreground opacity-0 shadow-md ring-1 ring-foreground/10 outline-none",
          className
        )}
        {...props}
        render={(nativeProps, state) => (
          <AnimatedPopup
            nativeProps={nativeProps}
            state={state}
            render={render}
            animated={animated}
          />
        )}
      />
    </Primitive.Positioner>
  </Primitive.Portal>
);

export const DropdownSubContent = ({
  side = "inline-end",
  alignOffset = -3,
  sideOffset = 0,
  ...props
}: DropdownContentProps) => (
  <DropdownContent
    side={side}
    alignOffset={alignOffset}
    sideOffset={sideOffset}
    {...props}
  />
);

const itemClass =
  "relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:ps-7 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
const destructiveClass =
  "data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 data-[variant=destructive]:data-highlighted:text-destructive";

export type DropdownItemProps = Primitive.Item.Props & {
  inset?: boolean;
  variant?: "default" | "destructive";
};
export const DropdownItem = ({
  className,
  inset,
  variant = "default",
  ...props
}: DropdownItemProps) => (
  <Primitive.Item
    data-slot="dropdown-item"
    data-inset={inset || undefined}
    data-variant={variant}
    className={cn(itemClass, destructiveClass, className)}
    {...props}
  />
);

export const DropdownLinkItem = ({
  className,
  ...props
}: Primitive.LinkItem.Props) => (
  <Primitive.LinkItem
    data-slot="dropdown-link-item"
    className={cn(itemClass, className)}
    {...props}
  />
);

export const DropdownSubTrigger = ({
  className,
  inset,
  children,
  ...props
}: Primitive.SubmenuTrigger.Props & { inset?: boolean }) => (
  <Primitive.SubmenuTrigger
    data-slot="dropdown-sub-trigger"
    data-inset={inset || undefined}
    className={cn(
      itemClass,
      "data-popup-open:bg-accent data-popup-open:text-accent-foreground",
      className
    )}
    {...props}
  >
    {children}
    <ChevronRightIcon aria-hidden="true" className="ms-auto rtl:rotate-180" />
  </Primitive.SubmenuTrigger>
);

export const DropdownLabel = ({
  className,
  inset,
  ...props
}: Primitive.GroupLabel.Props & { inset?: boolean }) => (
  <Primitive.GroupLabel
    data-slot="dropdown-label"
    data-inset={inset || undefined}
    className={cn(
      "px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:ps-7",
      className
    )}
    {...props}
  />
);
export const DropdownSeparator = ({
  className,
  ...props
}: Primitive.Separator.Props) => (
  <Primitive.Separator
    data-slot="dropdown-separator"
    className={cn("-mx-1 my-1 h-px bg-border", className)}
    {...props}
  />
);
export const DropdownShortcut = ({
  className,
  ...props
}: ComponentProps<"span">) => (
  <span
    data-slot="dropdown-shortcut"
    className={cn(
      "ms-auto text-xs tracking-widest text-muted-foreground",
      className
    )}
    {...props}
  />
);

export const DropdownCheckboxItem = ({
  className,
  children,
  inset,
  ...props
}: Primitive.CheckboxItem.Props & { inset?: boolean }) => (
  <Primitive.CheckboxItem
    data-slot="dropdown-checkbox-item"
    data-inset={inset || undefined}
    className={cn(itemClass, "pe-8", className)}
    {...props}
  >
    {children}
    <Primitive.CheckboxItemIndicator className="pointer-events-none absolute end-2 flex items-center justify-center">
      <CheckIcon aria-hidden="true" />
    </Primitive.CheckboxItemIndicator>
  </Primitive.CheckboxItem>
);
export const DropdownRadioItem = ({
  className,
  children,
  inset,
  ...props
}: Primitive.RadioItem.Props & { inset?: boolean }) => (
  <Primitive.RadioItem
    data-slot="dropdown-radio-item"
    data-inset={inset || undefined}
    className={cn(itemClass, "pe-8", className)}
    {...props}
  >
    {children}
    <Primitive.RadioItemIndicator className="pointer-events-none absolute end-2 flex items-center justify-center">
      <CheckIcon aria-hidden="true" />
    </Primitive.RadioItemIndicator>
  </Primitive.RadioItem>
);

interface DropdownEntryBase {
  id: string;
  label: string;
  icon?: ComponentType<SVGProps<SVGSVGElement>>;
  disabled?: boolean;
}
export interface DropdownAction extends DropdownEntryBase {
  type?: "item";
  shortcut?: string;
  variant?: "default" | "destructive";
  closeOnSelect?: boolean | "success";
  onSelect?: (
    event: Parameters<NonNullable<Primitive.Item.Props["onClick"]>>[0]
  ) => unknown;
  onSelectError?: (error: unknown) => void;
  link?: never;
  renderOverlay?: never;
}
export interface DropdownLink extends Omit<DropdownEntryBase, "disabled"> {
  type?: "item";
  shortcut?: string;
  link: ReactElement;
  closeOnSelect?: boolean;
  disabled?: never;
  variant?: never;
  onSelect?: never;
  onSelectError?: never;
  renderOverlay?: never;
}
export interface DropdownOverlayProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  finalFocus: RefObject<HTMLElement | null>;
}
export interface DropdownOverlay extends DropdownEntryBase {
  type?: "item";
  shortcut?: string;
  variant?: "default" | "destructive";
  renderOverlay: (props: DropdownOverlayProps) => ReactNode;
  link?: never;
  onSelect?: never;
  onSelectError?: never;
  closeOnSelect?: never;
}
export interface DropdownItemGroup {
  id: string;
  type: "group";
  label?: string;
  items: readonly DropdownEntry[];
}
export interface DropdownSubmenu extends DropdownEntryBase {
  type: "submenu";
  items: readonly DropdownEntry[];
}
export interface DropdownDivider {
  id: string;
  type: "separator";
}
export type DropdownEntry =
  | DropdownAction
  | DropdownLink
  | DropdownOverlay
  | DropdownItemGroup
  | DropdownSubmenu
  | DropdownDivider;

const DropdownItems = ({
  items,
  animated,
  path = [],
  pending,
  select,
  openOverlay,
  completeSelection,
}: {
  items: readonly DropdownEntry[];
  animated: boolean;
  path?: readonly string[];
  pending: string | null;
  select: (
    item: DropdownAction,
    key: string,
    event: Parameters<NonNullable<Primitive.Item.Props["onClick"]>>[0]
  ) => void;
  openOverlay: (key: string) => void;
  completeSelection: () => void;
}) => (
  <DropdownGroup>
    {items.map((item) => {
      const itemPath = [...path, item.id];
      const key = JSON.stringify(itemPath);
      if (item.type === "separator") {
        return <DropdownSeparator key={item.id} />;
      }
      if (item.type === "group") {
        return (
          <DropdownGroup key={item.id}>
            {item.label !== undefined && (
              <DropdownLabel>{item.label}</DropdownLabel>
            )}
            <DropdownItems
              items={item.items}
              animated={animated}
              path={itemPath}
              pending={pending}
              select={select}
              openOverlay={openOverlay}
              completeSelection={completeSelection}
            />
          </DropdownGroup>
        );
      }
      const Icon = item.icon;
      const label = (
        <>
          {pending === key ? (
            <LoaderCircleIcon
              aria-hidden="true"
              className="animate-spin motion-reduce:animate-none"
            />
          ) : (
            Icon && <Icon aria-hidden="true" />
          )}
          <span className="min-w-0 flex-1 break-words">{item.label}</span>
        </>
      );
      if (item.type === "submenu") {
        return (
          <DropdownSub
            key={item.id}
            disabled={item.disabled || pending !== null}
          >
            <DropdownSubTrigger
              disabled={item.disabled || pending !== null}
              label={item.label}
            >
              {label}
            </DropdownSubTrigger>
            <DropdownSubContent animated={animated}>
              <DropdownItems
                items={item.items}
                animated={animated}
                path={itemPath}
                pending={pending}
                select={select}
                openOverlay={openOverlay}
                completeSelection={completeSelection}
              />
            </DropdownSubContent>
          </DropdownSub>
        );
      }
      if (item.link) {
        // LinkItem has no disabled contract. A pending link becomes a disabled
        // action row so native/router navigation cannot bypass the action lock.
        if (pending !== null) {
          return (
            <DropdownItem key={item.id} disabled label={item.label}>
              {label}
            </DropdownItem>
          );
        }
        return (
          <DropdownLinkItem
            key={item.id}
            label={item.label}
            render={item.link}
            closeOnClick={item.closeOnSelect ?? true}
            onClick={(event) => {
              if (item.closeOnSelect === false) {
                return;
              }
              // The rendered link's handler and Base UI must finish first so
              // router cancellation and native modifier navigation survive.
              queueMicrotask(() => {
                if (
                  !event.defaultPrevented &&
                  !event.baseUIHandlerPrevented &&
                  !event.metaKey &&
                  !event.ctrlKey &&
                  !event.shiftKey &&
                  !event.altKey &&
                  event.button === 0
                ) {
                  completeSelection();
                }
              });
            }}
          >
            {label}
            {item.shortcut && (
              <DropdownShortcut>{item.shortcut}</DropdownShortcut>
            )}
          </DropdownLinkItem>
        );
      }
      return (
        <DropdownItem
          key={item.id}
          disabled={item.disabled || pending !== null}
          aria-busy={pending === key || undefined}
          label={item.label}
          variant={item.variant}
          closeOnClick={false}
          onClick={(event) => {
            if (item.renderOverlay) {
              openOverlay(key);
            } else {
              select(item, key, event);
            }
          }}
        >
          {label}
          {item.shortcut && (
            <DropdownShortcut>{item.shortcut}</DropdownShortcut>
          )}
        </DropdownItem>
      );
    })}
  </DropdownGroup>
);

export type DropdownProps = Omit<Primitive.Root.Props, "children"> & {
  trigger: ReactElement;
  items: readonly DropdownEntry[];
  animated?: boolean;
  triggerProps?: Omit<
    ComponentProps<typeof DropdownTrigger>,
    "children" | "render"
  >;
  contentProps?: Omit<DropdownContentProps, "children" | "animated">;
  /** Accepted action/link completion, never dismissal or overlay activation.
   * Async actions notify after fulfillment, while their owner is still mounted.
   */
  onSelectionComplete?: () => void;
};

const OverlayRenderer = ({
  item,
  ...props
}: DropdownOverlayProps & { item: DropdownOverlay }) => (
  <>{item.renderOverlay(props)}</>
);

export const Dropdown = ({
  trigger,
  items,
  animated = true,
  triggerProps,
  contentProps,
  actionsRef,
  onOpenChange,
  onOpenChangeComplete,
  onSelectionComplete,
  ...props
}: DropdownProps) => {
  const localActions = useRef<Primitive.Root.Actions | null>(null);
  const actions = actionsRef ?? localActions;
  const triggerRef = useRef<HTMLElement | null>(null);
  const { ref: consumerRef, ...nativeTriggerProps } = triggerProps ?? {};
  const renderedTrigger = useRender({
    defaultTagName: "button",
    ref: [triggerRef, consumerRef ?? null],
    render: trigger,
  });
  const [pending, setPending] = useState<string | null>(null);
  const pendingRef = useRef<string | null>(null);
  const [activeOverlay, setActiveOverlay] = useState<string | null>(null);
  const queuedOverlay = useRef<string | null>(null);
  const session = useRef(0);
  const observedOpen = useRef(props.open);
  const mounted = useRef(true);
  useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
    };
  }, []);
  // Controlled changes can happen without an onOpenChange request.
  useLayoutEffect(() => {
    // An accepted request already advanced the session synchronously. Do not
    // invalidate its async completion again when the controlled prop catches up.
    if (observedOpen.current !== props.open) {
      session.current += 1;
      observedOpen.current = props.open;
    }
    if (props.open) {
      queuedOverlay.current = null;
    }
  }, [props.open]);
  const close = () => actions.current?.close();
  const reportError = (item: DropdownAction, error: unknown) => {
    if (!mounted.current) {
      return;
    }
    if (item.onSelectError) {
      try {
        item.onSelectError(error);
      } catch (handlerError) {
        console.error("Dropdown onSelectError failed.", handlerError);
      }
    } else {
      console.error(
        "Dropdown action failed. Provide onSelectError to handle the error.",
        error
      );
    }
  };
  const select = (
    item: DropdownAction,
    key: string,
    event: Parameters<NonNullable<Primitive.Item.Props["onClick"]>>[0]
  ) => {
    if (pendingRef.current !== null || item.disabled) {
      return;
    }
    let result: unknown;
    try {
      result = item.onSelect?.(event);
    } catch (error) {
      reportError(item, error);
      return;
    }
    const shouldClose =
      !event.defaultPrevented && !event.baseUIHandlerPrevented;
    if (
      result &&
      (typeof result === "object" || typeof result === "function") &&
      "then" in result &&
      typeof result.then === "function"
    ) {
      pendingRef.current = key;
      setPending(key);
      if (
        item.closeOnSelect !== false &&
        item.closeOnSelect !== "success" &&
        shouldClose
      ) {
        close();
      }
      const completionSession = session.current;
      const settle = async () => {
        try {
          await result;
          if (
            mounted.current &&
            item.closeOnSelect !== false &&
            shouldClose &&
            session.current === completionSession
          ) {
            if (item.closeOnSelect === "success") {
              close();
            }
            onSelectionComplete?.();
          }
        } catch (error) {
          reportError(item, error);
        } finally {
          if (mounted.current) {
            pendingRef.current = null;
            setPending(null);
          }
        }
      };
      // The DOM callback stays synchronous; all rejections are handled above.
      void settle();
    } else if (item.closeOnSelect !== false && shouldClose) {
      close();
      onSelectionComplete?.();
    }
  };
  const overlays: { key: string; item: DropdownOverlay }[] = [];
  const collect = (
    entries: readonly DropdownEntry[],
    path: readonly string[] = []
  ) => {
    for (const item of entries) {
      const itemPath = [...path, item.id];
      if (item.type === "group" || item.type === "submenu") {
        collect(item.items, itemPath);
      } else if ("renderOverlay" in item && item.renderOverlay) {
        overlays.push({ item, key: JSON.stringify(itemPath) });
      }
    }
  };
  collect(items);
  return (
    <>
      <DropdownRoot
        {...props}
        actionsRef={actions}
        onOpenChange={(open, details) => {
          onOpenChange?.(open, details);
          if (details.isCanceled) {
            queuedOverlay.current = null;
            return;
          }
          session.current += 1;
          observedOpen.current = open;
          if (open) {
            queuedOverlay.current = null;
          }
        }}
        onOpenChangeComplete={(open) => {
          if (!open && queuedOverlay.current) {
            setActiveOverlay(queuedOverlay.current);
            queuedOverlay.current = null;
          }
          onOpenChangeComplete?.(open);
        }}
      >
        <DropdownTrigger {...nativeTriggerProps} render={renderedTrigger} />
        <DropdownContent
          {...contentProps}
          animated={animated}
          aria-busy={pending !== null || undefined}
        >
          <DropdownItems
            items={items}
            animated={animated}
            pending={pending}
            select={select}
            openOverlay={(key) => {
              if (pendingRef.current !== null) {
                return;
              }
              queuedOverlay.current = key;
              close();
            }}
            completeSelection={() => {
              if (mounted.current) {
                onSelectionComplete?.();
              }
            }}
          />
        </DropdownContent>
      </DropdownRoot>
      {overlays.map(({ key, item }) => (
        <OverlayRenderer
          key={key}
          item={item}
          open={activeOverlay === key}
          finalFocus={triggerRef}
          onOpenChange={(open) => {
            setActiveOverlay((current) => {
              if (open) {
                return key;
              }
              return current === key ? null : current;
            });
          }}
        />
      ))}
    </>
  );
};

```

## Credits [#credits]

* [shadcn/ui](https://ui.shadcn.com/docs/components/base/dropdown-menu): Adapted Base Nova dropdown-menu composition and styling with a data API and native Motion animations.

* [Base UI](https://base-ui.com/react/components/menu): Accessible menu primitives, keyboard navigation, positioning, and animation lifecycle.
