For the complete documentation index, see 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.
1
Support

Dropdown

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

Playground

Live preview

Last action: None

Shortcuts are display hints, not registered key bindings. Actions only update the demo status. Code reproduces configuration, not transient selection; Reset clears selection and closes the menu.
Customize Dropdown
Customize
dropdown-demo.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

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

$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/dropdown.json

Dependencies

Installed automatically by the registry CLI. For manual setup:

Dependencies4 required packages
  • @base-ui/react@^1.8.0
  • cn
  • lucide-react
  • motion@^12.38.0
$ pnpm add @base-ui/react@^1.8.0 cn lucide-react motion@^12.38.0

Usage

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

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.

Last action: None

dropdown-demo.tsx
"use client";

import { CopyIcon, PencilIcon, TrashIcon } from "lucide-react";
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
        trigger={
          <button
            type="button"
            className="rounded-md border border-input px-3 py-2 text-sm font-medium hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"
          >
            Actions
          </button>
        }
        items={[
          {
            icon: PencilIcon,
            id: "edit",
            label: "Edit",
            onSelect: () => setSelected("Edit"),
            shortcut: "⌘E",
          },
          {
            icon: CopyIcon,
            id: "duplicate",
            label: "Duplicate",
            onSelect: () => setSelected("Duplicate"),
          },
          { id: "divider", type: "separator" },
          {
            icon: TrashIcon,
            id: "delete",
            label: "Delete",
            onSelect: () => setSelected("Delete"),
            variant: "destructive",
          },
        ]}
      />
      <p role="status" className="text-sm text-muted-foreground">
        Last action: {selected}
      </p>
    </div>
  );
};

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.

Last action: None

dropdown-nested-demo.tsx
"use client";

import { useState } from "react";

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

export const DropdownNestedDemo = () => {
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState("None");
  return (
    <div className="flex flex-col items-center gap-4">
      <Dropdown
        open={open}
        onOpenChange={setOpen}
        trigger={
          <button
            type="button"
            className="rounded-md border border-input px-3 py-2 text-sm hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring"
          >
            Project
          </button>
        }
        items={[
          {
            id: "project",
            items: [
              {
                id: "rename",
                label: "Rename",
                onSelect: () => setSelected("Rename"),
              },
              {
                id: "share",
                items: [
                  {
                    id: "team",
                    label: "Team",
                    onSelect: () => setSelected("Team"),
                  },
                  { disabled: true, id: "public", label: "Public link" },
                ],
                label: "Share with",
                type: "submenu",
              },
            ],
            label: "Project actions",
            type: "group",
          },
        ]}
      />
      <p role="status" className="text-sm text-muted-foreground">
        Last action: {selected}
      </p>
    </div>
  );
};

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.

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.

Sidebar visible; sort by name.

dropdown-settings-demo.tsx
"use client";

import { useState } from "react";

import {
  DropdownRoot,
  DropdownTrigger,
  DropdownContent,
  DropdownGroup,
  DropdownLabel,
  DropdownCheckboxItem,
  DropdownSeparator,
  DropdownRadioGroup,
  DropdownRadioItem,
  DropdownLinkItem,
} from "@/components/ui/dropdown";

export const DropdownSettingsDemo = () => {
  const [sidebar, setSidebar] = useState(true);
  const [sort, setSort] = useState("name");
  return (
    <div className="flex flex-col items-center gap-4">
      <DropdownRoot>
        <DropdownTrigger className="rounded-md border border-input px-3 py-2 text-sm hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring">
          View settings
        </DropdownTrigger>
        <DropdownContent>
          <DropdownGroup>
            <DropdownLabel>Layout</DropdownLabel>
            <DropdownCheckboxItem
              checked={sidebar}
              onCheckedChange={setSidebar}
            >
              Show sidebar
            </DropdownCheckboxItem>
          </DropdownGroup>
          <DropdownSeparator />
          <DropdownGroup>
            <DropdownLabel>Sort by</DropdownLabel>
            <DropdownRadioGroup value={sort} onValueChange={setSort}>
              <DropdownRadioItem value="name" closeOnClick={false}>
                Name
              </DropdownRadioItem>
              <DropdownRadioItem value="date" closeOnClick={false}>
                Date modified
              </DropdownRadioItem>
            </DropdownRadioGroup>
          </DropdownGroup>
          <DropdownSeparator />
          <DropdownGroup>
            <DropdownLinkItem
              href="https://base-ui.com/react/components/menu"
              closeOnClick
            >
              Menu documentation
            </DropdownLinkItem>
          </DropdownGroup>
        </DropdownContent>
      </DropdownRoot>
      <p role="status" className="text-sm text-muted-foreground">
        Sidebar {sidebar ? "visible" : "hidden"}; sort by {sort}.
      </p>
    </div>
  );
};

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.

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.

dropdown-integrations-demo.tsx
"use client";

import Link from "next/link";

import { Dialog, DialogBody } from "@/components/ui/dialog";
import { Drawer, DrawerBody } from "@/components/ui/drawer";
import { Dropdown } from "@/components/ui/dropdown";
import type { DropdownOverlayProps } from "@/components/ui/dropdown";

const DetailsDialog = ({ finalFocus, ...props }: DropdownOverlayProps) => (
  <Dialog
    {...props}
    title="Project details"
    description="This dialog is rendered outside the menu popup."
    contentProps={{ finalFocus }}
  >
    <DialogBody>
      <p className="text-sm text-muted-foreground">
        Closing the dialog returns focus to the original menu trigger.
      </p>
    </DialogBody>
  </Dialog>
);

const DetailsDrawer = ({ finalFocus, ...props }: DropdownOverlayProps) => (
  <Drawer
    {...props}
    title="Project activity"
    description="A component-based drawer opened from an item."
    contentProps={{ finalFocus }}
  >
    <DrawerBody>
      <p className="text-sm text-muted-foreground">
        The drawer stays mounted when the menu popup disappears.
      </p>
    </DrawerBody>
  </Drawer>
);

export const DropdownIntegrationsDemo = () => (
  <Dropdown
    trigger={
      <button
        type="button"
        className="rounded-md border border-input bg-background px-3 py-2 text-sm font-medium hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"
      >
        Project actions
      </button>
    }
    items={[
      {
        id: "docs",
        label: "Read Dialog docs",
        link: <Link href="/docs/components/dialog" prefetch={false} />,
      },
      { id: "divider", type: "separator" },
      {
        id: "details",
        label: "Project details",
        renderOverlay: (props) => <DetailsDialog {...props} />,
      },
      {
        id: "activity",
        label: "Project activity",
        renderOverlay: (props) => <DetailsDrawer {...props} />,
      },
    ]}
  />
);

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

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.

No request sent.

dropdown-async-demo.tsx
"use client";

import { useState } from "react";

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

export const DropdownAsyncDemo = () => {
  const [fail, setFail] = useState(false);
  const [status, setStatus] = useState("No request sent.");
  return (
    <div className="flex flex-col items-center gap-4">
      <label className="flex items-center gap-2 text-sm">
        <input
          type="checkbox"
          checked={fail}
          onChange={(event) => setFail(event.target.checked)}
        />
        Simulate request failure
      </label>
      <Dropdown
        trigger={
          <button
            type="button"
            className="rounded-md border border-input bg-background px-3 py-2 text-sm font-medium hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"
          >
            Account actions
          </button>
        }
        items={[
          {
            id: "profile",
            label: "View profile",
            onSelect: () => setStatus("Profile selected."),
          },
          {
            id: "logout",
            label: "Logout (simulation)",
            variant: "destructive",
            closeOnSelect: "success",
            onSelect: async () => {
              setStatus("Sending simulated request…");
              // eslint-disable-next-line promise/avoid-new
              await new Promise<void>((resolve) => setTimeout(resolve, 800));
              if (fail) {
                throw new Error("Simulated request failed. Try again.");
              }
              setStatus("Simulated logout succeeded. No session was changed.");
            },
            onSelectError: (error) =>
              setStatus(
                error instanceof Error ? error.message : "Request failed."
              ),
          },
        ]}
      />
      <p role="status" className="text-sm text-muted-foreground">
        {status}
      </p>
    </div>
  );
};

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:

{
  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

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:

$ pnpm dlx 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

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

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
align"start" | "center" | "end"

contentProps / Content: alignment against the trigger.

"start"
animatedboolean

Dropdown / Content: native Motion fade and scale. Reduced motion skips animation automatically; compact API applies to submenus too.

true
checkedboolean

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.

Uncontrolled
closeOnSelectboolean | "success"

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.

true
compositionBase UI Menu props

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.

Not set
contentPropsDropdownContent props

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.

Not set
disabledboolean

Root / Dropdown: disables the entire menu. triggerProps.disabled can disable only its trigger.

false
itemsreadonly DropdownEntry[]

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.

Required
modalboolean

Root / Dropdown: modal menu behavior inherited from Base UI.

true
linkReactElement

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.

Not set
onSelect(event) => unknown

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.

Not set
onSelectError(error: unknown) => void

Compact action: handles synchronous exceptions or Promise rejection. Provide application feedback such as a toast. Not called after Dropdown unmounts.

Console error
onOpenChange(open, details) => void

Root / Dropdown: receives (open, details). details.cancel() rejects a request; externally changing open also works. onOpenChangeComplete fires after each completed transition.

Not set
openboolean

Root / Dropdown: controlled visibility. Pair with onOpenChange; defaultOpen initializes uncontrolled visibility (false).

Uncontrolled
renderOverlay(props: DropdownOverlayProps) => ReactNode

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.

Not set
showShortcutsboolean (demo)

Demo only: displays shortcut hints. Shortcuts do not register keyboard commands.

true (demo)
showSubmenuboolean (demo)

Demo only: adds a nested Share menu to the items array.

true (demo)
side"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end"

contentProps / Content: preferred side; collision handling may flip it. Also supports inline-start / inline-end.

"bottom"
triggerReactElement

Dropdown: one React element. Props and ref are merged onto it; use a native button or a ref-forwarding button component.

Required
triggerPropsDropdownTrigger props

Dropdown: Base UI Trigger props except children/render, including ref, nativeButton, aria-label and event handlers. Use nativeButton=false for non-button render elements.

Not set
variant"default" | "destructive"

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.

"default"

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

"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

  • shadcn/ui: Adapted Base Nova dropdown-menu composition and styling with a data API and native Motion animations.
  • Base UI: Accessible menu primitives, keyboard navigation, positioning, and animation lifecycle.