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.

VariantUse when
Label + chevronPrimary 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 actionCombined primary action + dropdown for secondary variants
Align: startDefault — panel left-edge aligns with trigger left-edge
Align: endPanel right-edge aligns with trigger right-edge — use for right-side toolbars

Anatomy

ElementSpec
Panel surfaceblur(20px) saturate(160%), --surface at 92% opacity, --shadow-sheet, 1px border
Specular top edge1px gradient line: transparent → white/15% → transparent
Action item13px, 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
Separator1px --border-subtle, my-1 vertical margin, role='separator'
Min width180px — grows to fit the longest item
Offset from trigger6px gap (mt-1.5 / mb-1.5)
Border radius--radius-md (10px)

Dropdown vs Context Menu

Dropdown MenuContext Menu
TriggerExplicit button clickRight-click / long-press
DiscoveryAlways visible (button label or ⋯)Hidden — user must know to right-click
PositionAnchored to the trigger elementFloats at cursor position
Best forToolbars, row actions, filter menusCanvas 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

PropTypeDefaultDescription
itemsDropdownItem[]Array of menu items. Use { type: 'separator' } to insert a divider between groups.
triggerReactNodeThe 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) => voidCalled 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.