# Dropdown Menu

Menu with icons, shortcuts, checkbox items, labels and destructive actions. Items cascade in and a highlight follows you.

**Category:** Navigation  
**Exports:** `DropdownMenu`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/dropdown-menu.json
```
The file lands at `components/ui/dropdown-menu.tsx`; import from `@/components/ui/dropdown-menu`.

## API — Dropdown Menu
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `trigger` | `ReactElement` | — |  |
| `items` | `MenuEntry[]` | — | item | checkbox | separator | label |
| `align` | `"start" \| "end"` | `"start"` |  |

## Example — Dropdown Menu

```tsx
import * as React from "react";
import { ChevronDown, Copy, LogOut, Pencil, Share2, Trash2 } from "lucide-react";
import { DropdownMenu } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { toast } from "@/components/ui/toast";

export default function DropdownMenuDemo({ align = "start", label = "Project options" }: { align?: "start" | "end"; label?: string }) {
  const [pinned, setPinned] = React.useState(true);
  return (
    <div className="pb-72">
      <DropdownMenu
        align={align}
        trigger={
          <Button variant="outline">
            <span>{label}</span>
            <ChevronDown />
          </Button>
        }
        items={[
          { type: "label", label: "Charcoal UI" },
          { label: "Rename", icon: <Pencil />, shortcut: "⌘R", onSelect: () => toast("Rename") },
          { label: "Duplicate", icon: <Copy />, shortcut: "⌘D", onSelect: () => toast("Duplicated") },
          { label: "Share", icon: <Share2 />, onSelect: () => toast("Link copied") },
          { type: "checkbox", label: "Pin to sidebar", checked: pinned, onCheckedChange: setPinned },
          { type: "separator" },
          { label: "Leave project", icon: <LogOut />, disabled: true },
          { label: "Delete project", icon: <Trash2 />, destructive: true, onSelect: () => toast.error("Project deleted") },
        ]}
      />
    </div>
  );
}
```

## Source — components/ui/dropdown-menu.tsx

```tsx
"use client";

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

export type MenuEntry =
  | { type?: "item"; label: string; icon?: React.ReactNode; shortcut?: string; destructive?: boolean; disabled?: boolean; onSelect?: () => void }
  | { type: "checkbox"; label: string; checked: boolean; onCheckedChange: (checked: boolean) => void }
  | { type: "separator" }
  | { type: "label"; label: string };

export type DropdownMenuProps = {
  trigger: React.ReactElement<Record<string, unknown>>;
  items: MenuEntry[];
  align?: "start" | "end";
  className?: string;
};

export function DropdownMenu({ trigger, items, align = "start", className }: DropdownMenuProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const [open, setOpen] = React.useState(false);
  const [active, setActive] = React.useState(-1);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const menuRef = React.useRef<HTMLDivElement>(null);
  const focusable = items.map((e, i) => (e.type === "separator" || e.type === "label" || ("disabled" in e && e.disabled) ? -1 : i)).filter((i) => i >= 0);

  const close = (refocus = true) => {
    setOpen(false);
    if (refocus) (rootRef.current?.querySelector("[data-menu-trigger]") as HTMLElement | null)?.focus();
  };

  React.useEffect(() => {
    if (!open) return;
    menuRef.current?.focus();
    const onDown = (e: PointerEvent) => !rootRef.current?.contains(e.target as Node) && close(false);
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [open]);

  const run = (i: number) => {
    const e = items[i];
    if (!e || e.type === "separator" || e.type === "label") return;
    if (e.type === "checkbox") return e.onCheckedChange(!e.checked);
    if (e.disabled) return;
    close();
    e.onSelect?.();
  };

  const move = (dir: 1 | -1) => {
    const pos = focusable.indexOf(active);
    setActive(focusable[(pos + dir + focusable.length) % focusable.length]);
  };

  return (
    <div ref={rootRef} className="relative inline-flex">
      {React.cloneElement(trigger, {
        "data-menu-trigger": "",
        "aria-haspopup": "menu",
        "aria-expanded": open,
        "aria-controls": id,
        onClick: () => (open ? close() : (setActive(focusable[0]), setOpen(true))),
        onKeyDown: (e: React.KeyboardEvent) => {
          if (e.key === "ArrowDown" || e.key === "ArrowUp") {
            e.preventDefault();
            setActive(e.key === "ArrowDown" ? focusable[0] : focusable[focusable.length - 1]);
            setOpen(true);
          }
        },
      })}
      <AnimatePresence>
        {open && (
          <motion.div
            ref={menuRef}
            id={id}
            role="menu"
            tabIndex={-1}
            aria-activedescendant={active >= 0 ? `${id}-${active}` : undefined}
            onKeyDown={(e) => {
              if (e.key === "ArrowDown") move(1);
              else if (e.key === "ArrowUp") move(-1);
              else if (e.key === "Home") setActive(focusable[0]);
              else if (e.key === "End") setActive(focusable[focusable.length - 1]);
              else if (e.key === "Enter" || e.key === " ") run(active);
              else if (e.key === "Escape") close();
              else if (e.key === "Tab") return close(false);
              else return;
              e.preventDefault();
            }}
            initial={{ opacity: 0, scale: reduce ? 1 : 0.95, y: reduce ? 0 : -4, filter: "blur(4px)" }}
            animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }}
            exit={{ opacity: 0, scale: reduce ? 1 : 0.97, filter: "blur(2px)", transition: { duration: 0.1 } }}
            transition={{ type: "spring", stiffness: 600, damping: 36 }}
            className={cn(
              "absolute top-full z-50 mt-2 min-w-56 rounded-xl border bg-popover p-1 text-popover-foreground shadow-[0_16px_40px_-12px_rgb(0_0_0/0.4)] outline-none",
              align === "start" ? "left-0 origin-top-left" : "right-0 origin-top-right",
              className,
            )}
          >
            <MenuList id={id} items={items} active={active} setActive={setActive} run={run} reduce={!!reduce} />
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function MenuList({ id, items, active, setActive, run, reduce }: { id: string; items: MenuEntry[]; active: number; setActive: (i: number) => void; run: (i: number) => void; reduce: boolean }) {
  return (
    <>
      {items.map((e, i) => {
        if (e.type === "separator") return <div key={i} role="separator" className="-mx-1 my-1 h-px bg-border" />;
        if (e.type === "label") return <p key={i} className="px-2.5 pb-1 pt-2 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">{e.label}</p>;
        const disabled = "disabled" in e && e.disabled;
        const destructive = "destructive" in e && e.destructive;
        return (
          <motion.div
            key={i}
            id={`${id}-${i}`}
            role={e.type === "checkbox" ? "menuitemcheckbox" : "menuitem"}
            aria-checked={e.type === "checkbox" ? e.checked : undefined}
            aria-disabled={disabled || undefined}
            onPointerMove={() => !disabled && active !== i && setActive(i)}
            onClick={() => run(i)}
            initial={{ opacity: 0, x: reduce ? 0 : -4 }}
            animate={{ opacity: 1, x: 0 }}
            transition={{ delay: reduce ? 0 : Math.min(i, 8) * 0.018 }}
            className={cn(
              "relative flex h-9 cursor-pointer select-none items-center gap-2.5 rounded-lg px-2.5 text-sm [&_svg]:size-4",
              disabled ? "cursor-not-allowed opacity-40" : destructive ? "text-destructive" : "",
            )}
          >
            {i === active && <motion.span layoutId={`${id}-hl`} className={cn("absolute inset-0 rounded-lg", destructive ? "bg-destructive/10" : "bg-accent")} transition={{ type: "spring", stiffness: 700, damping: 45 }} />}
            {e.type === "checkbox" ? (
              <span className="relative z-10 grid size-4 place-items-center">
                <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
                  <motion.path d="M5 12.5l4.5 4.5L19 7" initial={false} animate={{ pathLength: e.checked ? 1 : 0, opacity: e.checked ? 1 : 0 }} transition={{ duration: 0.2 }} />
                </svg>
              </span>
            ) : (
              e.icon && <span className={cn("relative z-10", !destructive && "text-muted-foreground")}>{e.icon}</span>
            )}
            <span className="relative z-10 flex-1">{e.label}</span>
            {"shortcut" in e && e.shortcut && <kbd className="relative z-10 font-mono text-[11px] text-muted-foreground">{e.shortcut}</kbd>}
          </motion.div>
        );
      })}
    </>
  );
}
```