Dropdown menu
Actions, checkboxes and radio options in a springy menu with full keyboard support.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/dropdown-menu.jsonOpen a menu, try the arrow keys
components/examples/dropdown-menu-demo.tsx
"use client";
import * as React from "react";
import { Copy, Ellipsis, Link2, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DropdownMenu } from "@/components/ui/dropdown-menu";
const sorts = ["Newest", "Oldest", "Most active"] as const;
export default function DropdownMenuDemo() {
const [sort, setSort] = React.useState<(typeof sorts)[number]>("Newest");
const [last, setLast] = React.useState<string | null>(null);
return (
<div className="flex flex-col items-center gap-6">
<div className="flex items-center gap-3">
<DropdownMenu
label={<>Sort by <span className="text-muted-foreground">{sort}</span></>}
items={sorts.map((s) => ({ type: "radio" as const, id: s, label: s, checked: s === sort, onSelect: () => setSort(s) }))}
/>
<DropdownMenu
align="end"
trigger={<Button size="icon-sm" variant="outline" aria-label="More actions"><Ellipsis /></Button>}
items={[
{ type: "label", id: "l", label: "Project" },
{ id: "rename", label: "Rename", icon: <Pencil />, shortcut: "R", onSelect: () => setLast("Rename") },
{ id: "dup", label: "Duplicate", icon: <Copy />, shortcut: "⌘D", onSelect: () => setLast("Duplicate") },
{ id: "link", label: "Copy link", icon: <Link2 />, onSelect: () => setLast("Copy link") },
{ type: "separator", id: "s" },
{ id: "delete", label: "Delete", icon: <Trash2 />, destructive: true, onSelect: () => setLast("Delete") },
]}
/>
</div>
<p className="h-5 text-sm text-muted-foreground">{last ? <>Chose <span className="text-foreground">{last}</span></> : "Open a menu, try the arrow keys"}</p>
</div>
);
}When to use
- A list of actions on an object: rename, duplicate, delete.
- View options with radio or checkbox items, like Sort by.
When not to use
- Setting a form value: use a select.
- Navigation: use links or a navigation menu.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/dropdown-menu.json1Install the dependencies
npm install motion lucide-react2Copy the source into your project
components/ui/dropdown-menu.tsx"use client"; import * as React from "react"; import { AnimatePresence, motion } from "motion/react"; import { Check, ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; export type MenuEntry = | { type?: "item"; id: string; label: string; icon?: React.ReactNode; shortcut?: string; destructive?: boolean; disabled?: boolean; onSelect?: () => void } | { type: "radio"; id: string; label: string; icon?: React.ReactNode; checked: boolean; onSelect?: () => void } | { type: "checkbox"; id: string; label: string; icon?: React.ReactNode; checked: boolean; onSelect?: () => void } | { type: "label"; id: string; label: string } | { type: "separator"; id: string }; export interface DropdownMenuProps { /** Text for the default trigger, or render your own with `trigger`. */ label?: React.ReactNode; trigger?: React.ReactElement<React.ButtonHTMLAttributes<HTMLButtonElement> & { ref?: React.Ref<HTMLButtonElement> }>; items: MenuEntry[]; align?: "start" | "end"; /** Keep open after checkbox items are toggled. */ closeOnSelect?: boolean; className?: string; } const isActionable = (e: MenuEntry) => e.type !== "label" && e.type !== "separator" && !("disabled" in e && e.disabled); export function DropdownMenu({ label, trigger, items, align = "start", closeOnSelect = true, className }: DropdownMenuProps) { const id = React.useId(); const [open, setOpen] = React.useState(false); const [active, setActive] = React.useState(-1); const rootRef = React.useRef<HTMLDivElement>(null); const triggerRef = React.useRef<HTMLButtonElement>(null); const menuRef = React.useRef<HTMLDivElement>(null); const actionable = items.map((e, i) => (isActionable(e) ? i : -1)).filter((i) => i >= 0); const close = (refocus = true) => { setOpen(false); if (refocus) triggerRef.current?.focus(); }; React.useEffect(() => { if (!open) return; menuRef.current?.focus(); const onDown = (e: PointerEvent) => !rootRef.current?.contains(e.target as Node) && setOpen(false); document.addEventListener("pointerdown", onDown); return () => document.removeEventListener("pointerdown", onDown); }, [open]); const choose = (index: number) => { const entry = items[index]; if (!entry || !isActionable(entry) || entry.type === "label" || entry.type === "separator") return; entry.onSelect?.(); if (entry.type !== "checkbox" || closeOnSelect) close(); }; const onKeyDown = (e: React.KeyboardEvent) => { const pos = actionable.indexOf(active); if (e.key === "ArrowDown") { e.preventDefault(); setActive(actionable[(pos + 1) % actionable.length]); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive(actionable[(pos - 1 + actionable.length) % actionable.length]); } else if (e.key === "Home") { e.preventDefault(); setActive(actionable[0]); } else if (e.key === "End") { e.preventDefault(); setActive(actionable[actionable.length - 1]); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); choose(active); } else if (e.key === "Escape") { e.preventDefault(); close(); } else if (e.key === "Tab") { close(false); } }; const openMenu = (focusFirst: boolean) => { setActive(focusFirst ? actionable[0] : -1); setOpen(true); }; const triggerProps = { ref: triggerRef, "aria-haspopup": "menu" as const, "aria-expanded": open, "aria-controls": open ? `${id}-menu` : undefined, onClick: () => (open ? close() : openMenu(false)), onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => { if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") { e.preventDefault(); openMenu(true); } }, }; return ( <div ref={rootRef} className={cn("relative inline-flex", className)}> {trigger ? ( React.cloneElement(trigger, triggerProps) ) : ( <button type="button" {...triggerProps} className={cn( "flex h-9 items-center gap-1.5 rounded-md border border-input px-3 text-sm font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring", open && "bg-accent", )} > {label} <motion.span animate={{ rotate: open ? 180 : 0 }} className="text-muted-foreground"> <ChevronDown className="size-3.5" /> </motion.span> </button> )} <AnimatePresence> {open && ( <motion.div ref={menuRef} id={`${id}-menu`} role="menu" tabIndex={-1} aria-activedescendant={active >= 0 ? `${id}-${active}` : undefined} onKeyDown={onKeyDown} initial={{ opacity: 0, scale: 0.95, y: -4 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.1 } }} transition={{ type: "spring", bounce: 0.15, duration: 0.28 }} style={{ transformOrigin: align === "end" ? "top right" : "top left" }} className={cn( "absolute top-full z-50 mt-1.5 min-w-48 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-xl outline-none", align === "end" ? "right-0" : "left-0", )} > {items.map((entry, i) => { if (entry.type === "separator") return <div key={entry.id} role="separator" className="-mx-1 my-1 h-px bg-border" />; if (entry.type === "label") return <div key={entry.id} className="px-2.5 pb-1 pt-2 text-xs font-medium text-muted-foreground">{entry.label}</div>; const disabled = "disabled" in entry && entry.disabled; const checked = entry.type === "radio" || entry.type === "checkbox" ? entry.checked : undefined; return ( <div key={entry.id} id={`${id}-${i}`} role={entry.type === "radio" ? "menuitemradio" : entry.type === "checkbox" ? "menuitemcheckbox" : "menuitem"} aria-checked={checked} aria-disabled={disabled || undefined} onPointerMove={() => !disabled && setActive(i)} onPointerLeave={() => setActive(-1)} onClick={() => !disabled && choose(i)} className={cn( "relative flex h-9 cursor-pointer select-none items-center gap-2.5 rounded-md px-2.5 text-sm [&_svg]:size-4", disabled && "cursor-default opacity-40", entry.type !== "radio" && entry.type !== "checkbox" && entry.destructive && "text-destructive", )} > {active === i && ( <motion.span layoutId={`${id}-hl`} className="absolute inset-0 rounded-md bg-accent" transition={{ type: "spring", bounce: 0, duration: 0.2 }} /> )} {entry.icon && <span className="relative text-muted-foreground">{entry.icon}</span>} <span className="relative flex-1">{entry.label}</span> {checked && <Check className="relative text-foreground" />} {entry.type !== "radio" && entry.type !== "checkbox" && entry.shortcut && ( <kbd className="relative font-sans text-[11px] text-muted-foreground">{entry.shortcut}</kbd> )} </div> ); })} </motion.div> )} </AnimatePresence> </div> ); }
Usage
Import it from @/components/ui/dropdown-menu after installing.
example.tsx
import { MoreHorizontal } from "lucide-react";
import { DropdownMenu } from "@/components/ui/dropdown-menu";
export function RowMenu({ onDelete }: { onDelete: () => void }) {
return (
<DropdownMenu
align="end"
trigger={<button type="button" aria-label="More actions"><MoreHorizontal /></button>}
items={[
{ id: "rename", label: "Rename", shortcut: "R" },
{ type: "separator", id: "s" },
{ id: "delete", label: "Delete", destructive: true, onSelect: onDelete },
]}
/>
);
}API reference
DropdownMenu
A trigger and a menu of items, radio items, checkbox items, labels and separators.
| Prop | Type | Default | Description |
|---|---|---|---|
| items | MenuEntry[] | – | { id, label, icon?, shortcut?, destructive?, disabled?, onSelect? } or radio/checkbox items with checked, or label/separator. |
| label | ReactNode | – | Text for the default trigger button. |
| trigger | ReactElement | – | Your own trigger; ARIA and handlers are added to it. |
| align | "start" | "end" | "start" | Menu alignment. |
| closeOnSelect | boolean | true | Set false to keep it open while toggling checkbox items. |
Keyboard interactions
| Keys | Action |
|---|---|
| EnterorSpaceor↓ | Open and focus the first item |
| ↑or↓ | Move between items |
| HomeorEnd | First or last item |
| EnterorSpace | Select |
| Esc | Close and return focus |
| Tab | Close |
Accessibility
- role="menu" with menuitem, menuitemradio and menuitemcheckbox.
- The trigger has aria-haspopup and aria-expanded.
- Disabled items are skipped by the keyboard.
Motion
- Scales in from its anchor corner; the highlight glides between items.
Responsive behavior
- At least 192px wide; aligns to the trigger edge.
Performance
- Items render only while open.
Notes for AI
- Ids must be unique.
- Use destructive for irreversible actions and confirm them in a dialog.
This page as Markdown, for assistants and coding agents:
/components/dropdown-menu/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt