Dropdown Menu
A button-anchored floating menu for actions and commands. Glass-backed panel, click-to-toggle, auto-dismisses on outside click or Escape. Supports icons, keyboard shortcuts, separators, and destructive items.
Preview
Variants
The trigger can be any button style — text label with chevron, icon-only, or a split button action area. The panel aligns to the trigger edge via the align prop.
| Variant | Use when |
|---|---|
| Label + chevron | Primary action area with a visible text label (Sort, Filter, Options) |
| Icon-only (⋯) | Compact row actions where space is limited — pair with aria-label |
| Split button action | Combined primary action + dropdown for secondary variants |
| Align: start | Default — panel left-edge aligns with trigger left-edge |
| Align: end | Panel right-edge aligns with trigger right-edge — use for right-side toolbars |
Anatomy
| Element | Spec |
|---|---|
| Panel surface | blur(20px) saturate(160%), --surface at 92% opacity, --shadow-sheet, 1px border |
| Specular top edge | 1px gradient line: transparent → white/15% → transparent |
| Action item | 13px, py-1.5, px-3, hover: --surface-raised. Optional leading icon (16px, 60% opacity) and trailing kbd shortcut |
| Destructive item | --status-danger text, hover: --status-danger/10% background. Always last, always after a separator |
| Separator | 1px --border-subtle, my-1 vertical margin, role='separator' |
| Min width | 180px — grows to fit the longest item |
| Offset from trigger | 6px gap (mt-1.5 / mb-1.5) |
| Border radius | --radius-md (10px) |
Dropdown vs Context Menu
| Dropdown Menu | Context Menu | |
|---|---|---|
| Trigger | Explicit button click | Right-click / long-press |
| Discovery | Always visible (button label or ⋯) | Hidden — user must know to right-click |
| Position | Anchored to the trigger element | Floats at cursor position |
| Best for | Toolbars, row actions, filter menus | Canvas items, list rows, text selections |
Implementation
DropdownMenu.tsx
tsx
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib";
export interface DropdownAction {
type?: "action";
label: string;
icon?: React.ReactNode;
shortcut?: string;
destructive?: boolean;
disabled?: boolean;
onSelect: () => void;
}
export interface DropdownSeparator { type: "separator" }
export type DropdownItem = DropdownAction | DropdownSeparator;
interface DropdownMenuProps {
items: DropdownItem[];
trigger: React.ReactNode;
align?: "start" | "end";
side?: "top" | "bottom";
onOpenChange?: (open: boolean) => void;
}
export function DropdownMenu({
items,
trigger,
align = "start",
side = "bottom",
onOpenChange,
}: DropdownMenuProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const toggle = () => {
const next = !open;
setOpen(next);
onOpenChange?.(next);
};
const close = useCallback(() => {
setOpen(false);
onOpenChange?.(false);
}, [onOpenChange]);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent | KeyboardEvent) => {
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
if (e instanceof MouseEvent) {
if (triggerRef.current?.contains(e.target as Node)) return;
if (panelRef.current?.contains(e.target as Node)) return;
}
close();
};
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", handler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", handler);
};
}, [open, close]);
return (
<div className="relative inline-flex">
<button ref={triggerRef} onClick={toggle} aria-haspopup="menu" aria-expanded={open}>
{trigger}
</button>
{open && (
<div
ref={panelRef}
role="menu"
className={cn(
"absolute z-50 min-w-[180px] py-1 rounded-[var(--radius-md)] border border-[rgb(var(--border))]",
side === "bottom" ? "top-full mt-1.5" : "bottom-full mb-1.5",
align === "end" ? "right-0" : "left-0"
)}
style={{
background: "rgb(var(--surface) / 0.92)",
backdropFilter: "blur(20px) saturate(160%)",
WebkitBackdropFilter: "blur(20px) saturate(160%)",
boxShadow: "var(--shadow-sheet), inset 0 1px 0 rgba(255,255,255,0.08)",
}}
>
<div className="absolute inset-x-0 top-0 h-px rounded-t-[var(--radius-md)] bg-gradient-to-r from-transparent via-white/15 to-transparent pointer-events-none" />
{items.map((item, i) => {
if (item.type === "separator") {
return <div key={i} className="my-1 h-px bg-[rgb(var(--border-subtle))]" role="separator" />;
}
const action = item as DropdownAction;
return (
<button
key={i}
role="menuitem"
disabled={action.disabled}
onClick={() => { action.onSelect(); close(); }}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-1.5 text-[13px] text-left transition-colors duration-75",
action.destructive
? "text-[rgb(var(--status-danger))] hover:bg-[rgb(var(--status-danger)/0.1)]"
: "text-[rgb(var(--text-primary))] hover:bg-[rgb(var(--surface-raised))]",
action.disabled && "opacity-40 cursor-not-allowed pointer-events-none"
)}
>
{action.icon && <span className="w-4 h-4 shrink-0 opacity-60">{action.icon}</span>}
<span className="flex-1">{action.label}</span>
{action.shortcut && (
<kbd className="font-mono text-[10px] text-[rgb(var(--text-tertiary))] ml-4">{action.shortcut}</kbd>
)}
</button>
);
})}
</div>
)}
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | DropdownItem[] | — | Array of menu items. Use { type: 'separator' } to insert a divider between groups. |
trigger | ReactNode | — | The trigger element. Rendered as-is — the Dropdown wraps it in a button if needed. |
align | "start" | "end" | "start" | Horizontal alignment of the panel relative to the trigger. |
side | "top" | "bottom" | "bottom" | Preferred vertical side. Flips automatically if the panel would overflow the viewport. |
onOpenChange | (open: boolean) => void | — | Called when the panel opens or closes. |
Accessibility
- →Set aria-haspopup='menu' and aria-expanded on the trigger button. Update aria-expanded when the panel opens and closes.
- →Set role='menu' on the panel and role='menuitem' on each action. The panel must not be focusable itself.
- →Arrow keys (↑ ↓) navigate items; Enter / Space activates; Escape closes and returns focus to trigger.
- →For icon-only triggers, provide aria-label describing the menu purpose (e.g. 'Row actions', 'Sort options').
- →Destructive items should always be last and separated by a divider so they are not accidentally activated.
- →Disabled items use aria-disabled='true' and pointer-events-none — they remain in the tab order but do not execute.