# Dropdown Menu

> Dropdown menu with labels, checkbox items, shortcuts, destructive actions and typeahead.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/dropdown-menu
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/dropdown-menu.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/dropdown-menu-demo.tsx"
"use client"

import * as React from "react"
import { ChevronDown, Copy, CreditCard, LogOut, Pencil, Settings, Trash2, User } from "lucide-react"

import { DropdownMenu } from "@/components/dropdown-menu"

export default function DropdownMenuDemo() {
  const [last, setLast] = React.useState("Nothing yet")
  const [compact, setCompact] = React.useState(false)
  const [grid, setGrid] = React.useState(true)

  return (
    <div className="flex h-96 w-full max-w-lg flex-col gap-6">
      <div className="flex items-start justify-between">
        <DropdownMenu
          defaultOpen
          trigger={
            <>
              <span className="flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-semibold text-primary-foreground">
                AL
              </span>
              Ada Lovelace
              <ChevronDown className="size-4 opacity-60" />
            </>
          }
          items={[
            { type: "label", label: "My account" },
            { label: "Profile", icon: User, shortcut: "⇧⌘P", onSelect: () => setLast("Profile") },
            { label: "Billing", icon: CreditCard, shortcut: "⌘B", onSelect: () => setLast("Billing") },
            { label: "Settings", icon: Settings, shortcut: "⌘,", onSelect: () => setLast("Settings") },
            { type: "separator" },
            { label: "Log out", icon: LogOut, onSelect: () => setLast("Log out") },
          ]}
        />
        <DropdownMenu
          align="end"
          width={200}
          trigger={<>View</>}
          items={[
            { type: "label", label: "Display" },
            { type: "checkbox", label: "Compact rows", checked: compact, onCheckedChange: setCompact },
            { type: "checkbox", label: "Show grid", checked: grid, onCheckedChange: setGrid },
            { type: "separator" },
            { label: "Rename", icon: Pencil, onSelect: () => setLast("Rename") },
            { label: "Duplicate", icon: Copy, disabled: true },
            { label: "Delete", icon: Trash2, destructive: true, onSelect: () => setLast("Delete") },
          ]}
        />
      </div>
      <p className="mt-auto text-center text-xs text-muted-foreground">
        Last action: <span className="font-medium text-foreground">{last}</span> · compact {compact ? "on" : "off"} · grid{" "}
        {grid ? "on" : "off"}
      </p>
    </div>
  )
}
```

## Source

```tsx title="components/dropdown-menu.tsx"
"use client"

import * as React from "react"
import { Check } from "lucide-react"

import { cn } from "@/lib/utils"

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

export interface DropdownMenuProps extends Omit<React.ComponentProps<"div">, "children"> {
  /** Content of the trigger button */
  trigger: React.ReactNode
  items: MenuEntry[]
  align?: "start" | "end"
  /** Width of the menu in px */
  width?: number
  triggerClassName?: string
  /** Render the menu open on mount (without moving focus) */
  defaultOpen?: boolean
}

export function DropdownMenu({
  trigger,
  items,
  align = "start",
  width = 224,
  triggerClassName,
  defaultOpen = false,
  className,
  ...props
}: DropdownMenuProps) {
  const [open, setOpen] = React.useState(defaultOpen)
  const root = React.useRef<HTMLDivElement>(null)
  const button = React.useRef<HTMLButtonElement>(null)
  const menu = React.useRef<HTMLDivElement>(null)
  const typed = React.useRef({ text: "", at: 0 })
  const id = React.useId()

  const options = () => [...(menu.current?.querySelectorAll<HTMLElement>("[data-menu-item]:not([aria-disabled=true])") ?? [])]
  const focusAt = (index: number) => {
    const all = options()
    all[(index + all.length) % all.length]?.focus()
  }

  React.useEffect(() => {
    if (!open) return
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen(false)
    }
    // Escape from inside is handled below; this covers an open menu without focus (defaultOpen)
    const onDocumentKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape" && !root.current?.contains(document.activeElement)) setOpen(false)
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onDocumentKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onDocumentKeyDown)
    }
  }, [open])

  const openMenu = (focus: "first" | "last") => {
    setOpen(true)
    requestAnimationFrame(() => focusAt(focus === "first" ? 0 : -1))
  }

  const close = () => {
    setOpen(false)
    button.current?.focus()
  }

  const onMenuKeyDown = (event: React.KeyboardEvent) => {
    const all = options()
    const index = all.indexOf(document.activeElement as HTMLElement)
    if (event.key === "ArrowDown") focusAt(index + 1)
    else if (event.key === "ArrowUp") focusAt(index - 1)
    else if (event.key === "Home") focusAt(0)
    else if (event.key === "End") focusAt(-1)
    else if (event.key === "Escape") close()
    else if (event.key === "Tab") setOpen(false)
    else if (event.key.length === 1 && /\S/.test(event.key)) {
      // Typeahead: jump to the next item starting with the typed letters
      const now = event.timeStamp
      typed.current = { text: (now - typed.current.at < 600 ? typed.current.text : "") + event.key.toLowerCase(), at: now }
      const match = all.find((el) => el.textContent?.trim().toLowerCase().startsWith(typed.current.text))
      match?.focus()
      return
    } else return
    event.preventDefault()
  }

  return (
    <div ref={root} className={cn("relative inline-block", className)} {...props}>
      <button
        ref={button}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={id}
        onClick={() => (open ? setOpen(false) : openMenu("first"))}
        onKeyDown={(event) => {
          if (event.key === "ArrowDown" || event.key === "ArrowUp") {
            event.preventDefault()
            openMenu(event.key === "ArrowDown" ? "first" : "last")
          }
        }}
        className={cn(
          "inline-flex h-9 items-center gap-2 rounded-lg border bg-background px-3 text-sm font-medium shadow-xs transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none aria-expanded:bg-accent",
          triggerClassName
        )}
      >
        {trigger}
      </button>
      {open && (
        <div
          ref={menu}
          id={id}
          role="menu"
          onKeyDown={onMenuKeyDown}
          style={{ width }}
          className={cn(
            "absolute top-full z-30 mt-1.5 rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg animate-in fade-in zoom-in-95 slide-in-from-top-1",
            align === "end" ? "right-0 origin-top-right" : "left-0 origin-top-left"
          )}
        >
          {items.map((entry, index) => {
            if (entry.type === "separator") return <div key={index} role="separator" className="-mx-1 my-1 h-px bg-border" />
            if (entry.type === "label")
              return (
                <p key={index} className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
                  {entry.label}
                </p>
              )
            const base =
              "flex h-8 w-full cursor-default items-center gap-2 rounded-md px-2 text-left text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground"
            if (entry.type === "checkbox")
              return (
                <div
                  key={index}
                  data-menu-item
                  role="menuitemcheckbox"
                  aria-checked={entry.checked}
                  tabIndex={-1}
                  onPointerMove={(event) => event.currentTarget.focus()}
                  onClick={() => entry.onCheckedChange(!entry.checked)}
                  onKeyDown={(event) => {
                    if (event.key === "Enter" || event.key === " ") {
                      event.preventDefault()
                      entry.onCheckedChange(!entry.checked)
                    }
                  }}
                  className={base}
                >
                  <Check className={cn("size-4", !entry.checked && "invisible")} />
                  {entry.label}
                </div>
              )
            const Icon = entry.icon
            const select = () => {
              if (entry.disabled) return
              entry.onSelect?.()
              close()
            }
            return (
              <div
                key={index}
                data-menu-item
                role="menuitem"
                aria-disabled={entry.disabled}
                tabIndex={-1}
                onPointerMove={(event) => !entry.disabled && event.currentTarget.focus()}
                onClick={select}
                onKeyDown={(event) => {
                  if (event.key === "Enter" || event.key === " ") {
                    event.preventDefault()
                    select()
                  }
                }}
                className={cn(
                  base,
                  entry.destructive && "text-destructive focus:bg-destructive/10 focus:text-destructive",
                  entry.disabled && "pointer-events-none opacity-50"
                )}
              >
                {Icon && <Icon className="size-4 text-current opacity-70" />}
                <span className="flex-1">{entry.label}</span>
                {entry.shortcut && <span className="text-xs tracking-widest text-muted-foreground">{entry.shortcut}</span>}
              </div>
            )
          })}
        </div>
      )}
    </div>
  )
}
```
