Components

Getting started

Components144

More

Actions behind a button: the menu rises off the trigger, one highlight glides between rows, and checkbox rows draw their tick in place.

Used forRow actionsAccount menusView optionsCreate menus

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/dropdown-menu.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Row actions

    Edit, duplicate, delete on a "…" button.

  • Account menus

    Profile, settings, sign out under an avatar.

  • View options

    Checkable columns or display settings.

  • Create menus

    New doc, new folder, upload.

What it handles for you

Menus need roving focus, typeahead, Home/End and focus return; the gliding highlight is what makes it feel made rather than assembled.

Instead of wiring upRadix DropdownMenufloating-uiroving tabindex
  1. Gliding highlight

    One fill moves between rows (220ms) for pointer and keys alike.

  2. Keyboard

    Enter/Space/↓ open on the first row, ↑ on the last; arrows wrap; typing jumps.

  3. Checkable rows

    A drawn tick, aria-checked, menu stays open.

  4. Plain data

    items: rows, labels and separators as objects.

Overview

Menus usually repaint each row's background as you move, so the highlight blinks from row to row. Here one highlight glides, typing jumps to a row, checkbox rows draw their tick and keep the menu open, and destructive rows are red. It rises off its trigger and flips above when there is no room.

States

open
Rises off the trigger (320ms); flips above if needed.
move
The highlight glides to the row under the pointer or the key.
pick
Closes (180ms) and focus returns to the trigger; checkbox rows stay open and draw their tick (280ms).

Usage

usage.tsx
import { DropdownMenu } from "@/components/ui/dropdown-menu";<DropdownMenu  trigger={<button aria-label="More">…</button>}  items={[    { id: "rename", label: "Rename", shortcut: "F2", onSelect: rename },    { type: "separator" },    { id: "delete", label: "Delete", destructive: true, onSelect: remove },  ]}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Props

PropTypeDescription
triggerReactElementThe button that opens it.
itemsMenuEntry[]Rows ({ id, label, icon, shortcut, destructive, disabled, onSelect }), { type: "checkbox", checked, onCheckedChange }, { type: "label" } and { type: "separator" }.
side / alignSide / Aligndefault "bottom" / "start"Where it sits.
open / onOpenChangeboolean / (open) => voidControl it.
minWidthnumberdefault 200Never narrower than the trigger.
labelstringAccessible name of the menu.

Accessibility and motion

  • Placement, presence and dismissal live in floating.ts, shared by tooltip, popover, dropdown and context menus: they all flip, shift, enter and leave the same way.
  • role="menu" with menuitem and menuitemcheckbox rows.
  • Reduced motion: the menu fades; the highlight jumps.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/dropdown-menu.tsx
"use client";import * as React from "react";import { createPortal } from "react-dom";import { enterStyle, themeVars, useDismiss, useFloating, usePresence, type Align, type Side } from "./floating";import { MenuList, type MenuEntry } from "./menu-list";import { useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * DROPDOWN MENU: actions behind a button * *   open     the menu rises off the trigger out of a blur, from *            the point on the trigger nearest to it; it flips *            above when there's no room below *   move     one highlight glides between rows *   pick     the menu closes and focus goes back to the trigger; *            checkbox rows draw their tick and stay open * * Opening from the keyboard (Enter, Space, ↓) focuses the first * row; ↑ focuses the last. Escape or a press outside closes it. * ───────────────────────────────────────────────────────── */export type { MenuEntry } from "./menu-list";export interface DropdownMenuProps {  /** The button that opens it. It gets aria-haspopup, aria-expanded and the click. */  trigger: React.ReactElement<React.HTMLAttributes<HTMLElement>>;  items: MenuEntry[];  side?: Side;  align?: Align;  open?: boolean;  onOpenChange?: (open: boolean) => void;  /** Accessible name of the menu. */  label?: string;  /** Minimum width in px; never narrower than the trigger. */  minWidth?: number;}export function DropdownMenu({ trigger, items, side = "bottom", align = "start", open: controlled, onOpenChange, label, minWidth = 200 }: DropdownMenuProps) {  const reduced = useInteractionReducedMotion();  const anchor = React.useRef<HTMLSpanElement>(null);  const surface = React.useRef<HTMLDivElement>(null);  const [own, setOwn] = React.useState(false);  const [focusOn, setFocusOn] = React.useState<"first" | "last" | "none">("none");  const open = controlled ?? own;  const { mounted, shown } = usePresence(open, 180);  const place = useFloating({ open: mounted, anchor, floating: surface, side, align, offset: 6 });  const [vars, setVars] = React.useState<React.CSSProperties>({});  const id = React.useId();  const triggerId = React.useId();  const setOpen = (next: boolean, refocus = false) => {    if (controlled === undefined) setOwn(next);    onOpenChange?.(next);    // By id rather than a ref: this runs from handlers that are created while rendering.    if (!next && refocus) document.getElementById(trigger.props.id ?? triggerId)?.focus();  };  useDismiss(open, (reason) => setOpen(false, reason === "escape"), [anchor, surface]);  const [width, setWidth] = React.useState(minWidth);  React.useLayoutEffect(() => {    if (!mounted) return;    setVars(themeVars(anchor.current));    setWidth(Math.max(minWidth, anchor.current?.offsetWidth ?? 0));  }, [mounted, minWidth]);  return (    <>      <span ref={anchor} className="inline-flex">        {React.cloneElement(trigger, {          id: trigger.props.id ?? triggerId,          "aria-haspopup": "menu",          "aria-expanded": open,          "aria-controls": open ? id : undefined,          onClick: (e: React.MouseEvent<HTMLElement>) => {            trigger.props.onClick?.(e);            setFocusOn(e.detail === 0 ? "first" : "none");            setOpen(!open);          },          onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {            trigger.props.onKeyDown?.(e);            if (e.key === "ArrowDown" || e.key === "ArrowUp") {              e.preventDefault();              setFocusOn(e.key === "ArrowDown" ? "first" : "last");              setOpen(true);            }          },        })}      </span>      {mounted &&        createPortal(          <div            ref={surface}            id={id}            className="fixed left-0 top-0 z-[55] overflow-hidden rounded-[14px] bg-popover text-popover-foreground shadow-[0_0_0_1px_var(--border)]"            style={{ ...vars, left: place.x, top: place.y, minWidth: width, transformOrigin: place.origin, ...enterStyle(shown, place.side, reduced) }}          >            <MenuList items={items} label={label} autoFocus={focusOn} onClose={() => setOpen(false, true)} />          </div>,          document.body,        )}    </>  );}export default DropdownMenu;
components/floating.ts
"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * FLOATING: what every popover, menu and tooltip shares * *   place     beside an anchor on a side, lined up start, centre *             or end; flips to the other side when it doesn't fit *             and shifts to stay `padding` inside the viewport; *             follows scroll and resize *   presence  mounts on open, shows a frame later (so the entry *             transitions run), hides, then unmounts after `exit` *   dismiss   Escape and a press outside both close it *   theme     the portal carries the anchor's theme tokens, so a *             light specimen on a dark page stays light * ───────────────────────────────────────────────────────── */export type Side = "top" | "bottom" | "left" | "right";export type Align = "start" | "center" | "end";const TOKENS = ["--background", "--foreground", "--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground", "--primary", "--primary-foreground", "--muted", "--muted-foreground", "--ring", "--card"];/** The anchor's theme tokens as inline styles for something portalled out of its tree. */export function themeVars(anchor: Element | null): React.CSSProperties {  if (!anchor) return {};  const style = getComputedStyle(anchor);  const vars: Record<string, string> = {};  for (const t of TOKENS) {    const v = style.getPropertyValue(t);    if (v) vars[t] = v;  }  return vars as React.CSSProperties;}const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };export interface Placement {  x: number;  y: number;  side: Side;  /** Where the surface grows from, for transform-origin. */  origin: string;  ready: boolean;}/** Positions `floating` (position: fixed) next to `anchor` while `open`. Pass a point instead of an anchor for context menus. */export function useFloating({  open,  anchor,  point,  floating,  side = "bottom",  align = "center",  offset = 8,  padding = 8,}: {  open: boolean;  anchor?: React.RefObject<Element | null>;  point?: { x: number; y: number } | null;  floating: React.RefObject<HTMLElement | null>;  side?: Side;  align?: Align;  offset?: number;  padding?: number;}): Placement {  const [place, setPlace] = React.useState<Placement>({ x: -9999, y: -9999, side, origin: "center", ready: false });  React.useLayoutEffect(() => {    if (!open) return;    let frame = 0;    const measure = () => {      const el = floating.current;      if (!el) return;      const a = point        ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 }        : anchor?.current?.getBoundingClientRect();      if (!a) return;      const w = el.offsetWidth;      const h = el.offsetHeight;      const vw = window.innerWidth;      const vh = window.innerHeight;      const room: Record<Side, number> = { top: a.top - padding, bottom: vh - a.bottom - padding, left: a.left - padding, right: vw - a.right - padding };      const need = side === "top" || side === "bottom" ? h + offset : w + offset;      const s = room[side] < need && room[OPPOSITE[side]] > room[side] ? OPPOSITE[side] : side;      let x: number;      let y: number;      if (s === "top" || s === "bottom") {        y = s === "bottom" ? a.bottom + offset : a.top - offset - h;        x = align === "start" ? a.left : align === "end" ? a.right - w : a.left + a.width / 2 - w / 2;        if (point) x = a.left;      } else {        x = s === "right" ? a.right + offset : a.left - offset - w;        y = align === "start" ? a.top : align === "end" ? a.bottom - h : a.top + a.height / 2 - h / 2;      }      x = Math.max(padding, Math.min(vw - padding - w, x));      y = Math.max(padding, Math.min(vh - padding - h, y));      // Grow from the point on the anchor's edge nearest the surface.      const ox = s === "left" ? "100%" : s === "right" ? "0%" : `${Math.max(0, Math.min(w, a.left + a.width / 2 - x))}px`;      const oy = s === "top" ? "100%" : s === "bottom" ? "0%" : `${Math.max(0, Math.min(h, a.top + a.height / 2 - y))}px`;      const next = { x: Math.round(x), y: Math.round(y), side: s, origin: `${ox} ${oy}`, ready: true };      setPlace((p) => (p.x === next.x && p.y === next.y && p.side === next.side && p.origin === next.origin && p.ready ? p : next));    };    const schedule = () => {      cancelAnimationFrame(frame);      frame = requestAnimationFrame(measure);    };    measure();    const ro = new ResizeObserver(schedule);    if (floating.current) ro.observe(floating.current);    window.addEventListener("scroll", schedule, { capture: true, passive: true });    window.addEventListener("resize", schedule);    return () => {      cancelAnimationFrame(frame);      ro.disconnect();      window.removeEventListener("scroll", schedule, { capture: true });      window.removeEventListener("resize", schedule);    };  }, [open, anchor, point, floating, side, align, offset, padding]);  return open ? place : { ...place, ready: false };}/** Mount → show next frame → hide → unmount after `exit` ms. */export function usePresence(open: boolean, exit = 160) {  const [mounted, setMounted] = React.useState(open);  const [shown, setShown] = React.useState(false);  if (open && !mounted) setMounted(true);  React.useEffect(() => {    if (open) {      let inner = 0;      const outer = requestAnimationFrame(() => {        inner = requestAnimationFrame(() => setShown(true));      });      return () => {        cancelAnimationFrame(outer);        cancelAnimationFrame(inner);      };    }    const timer = window.setTimeout(() => {      setMounted(false);      setShown(false);    }, exit);    return () => window.clearTimeout(timer);  }, [open, exit]);  return { mounted: mounted || open, shown: open && shown };}/** Escape and presses outside `refs` call onDismiss while `open`. */export function useDismiss(open: boolean, onDismiss: (reason: "escape" | "outside") => void, refs: React.RefObject<Element | null>[]) {  const latest = React.useRef({ onDismiss, refs });  React.useEffect(() => {    latest.current = { onDismiss, refs };  });  React.useEffect(() => {    if (!open) return;    const onKey = (e: KeyboardEvent) => {      if (e.key !== "Escape") return;      e.stopPropagation();      latest.current.onDismiss("escape");    };    const onDown = (e: PointerEvent) => {      const target = e.target as Node;      if (latest.current.refs.some((r) => r.current?.contains(target))) return;      latest.current.onDismiss("outside");    };    document.addEventListener("keydown", onKey);    document.addEventListener("pointerdown", onDown, true);    return () => {      document.removeEventListener("keydown", onKey);      document.removeEventListener("pointerdown", onDown, true);    };  }, [open]);}/** The entry pose for a surface on `side`: it comes 6px from the anchor, 0.96 and blurred. */export function enterStyle(shown: boolean, side: Side, reduced: boolean, ease = "cubic-bezier(0.16,1,0.3,1)"): React.CSSProperties {  const d = 6;  const shift = side === "top" ? `0 ${d}px` : side === "bottom" ? `0 -${d}px` : side === "left" ? `${d}px 0` : `-${d}px 0`;  return {    opacity: shown ? 1 : 0,    translate: shown || reduced ? "0 0" : shift,    scale: shown || reduced ? "1" : "0.96",    filter: shown || reduced ? "blur(0px)" : "blur(4px)",    transition: reduced      ? "opacity 120ms ease-out"      : shown        ? `opacity 220ms ${ease}, translate 320ms ${ease}, scale 320ms ${ease}, filter 220ms ${ease}`        : `opacity 140ms ${ease}, translate 180ms ${ease}, scale 180ms ${ease}, filter 140ms ${ease}`,  };}
components/menu-list.tsx
"use client";import * as React from "react";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * MENU LIST: the rows inside a dropdown or context menu * *   highlight  one fill glides between rows as the pointer or *              arrow keys move; it never blinks off and on *   keys       arrows (wrapping), Home/End, typing a few letters *              jumps to a row, Enter/Space picks *   check      checkbox rows draw their tick in place and keep *              the menu open * ───────────────────────────────────────────────────────── */export type MenuEntry =  | { type?: "item"; id: string; label: string; icon?: React.ReactNode; shortcut?: string; destructive?: boolean; disabled?: boolean; onSelect?: () => void }  | { type: "checkbox"; id: string; label: string; icon?: React.ReactNode; checked: boolean; onCheckedChange: (checked: boolean) => void; disabled?: boolean }  | { type: "label"; label: string }  | { type: "separator" };type Row = Extract<MenuEntry, { id: string }>;export interface MenuListProps {  items: MenuEntry[];  /** Called after an item (not a checkbox) is picked. */  onClose: () => void;  /** Focus the first row on mount (keyboard open). */  autoFocus?: "first" | "last" | "none";  label?: string;}function Tick({ on, reduced }: { on: boolean; reduced: boolean }) {  return (    <svg viewBox="0 0 16 16" fill="none" className="size-3.5">      <path        d="M3.5 8.5 6.5 11.5 12.5 4.5"        stroke="currentColor"        strokeWidth="1.8"        strokeLinecap="round"        strokeLinejoin="round"        pathLength={1}        strokeDasharray={1}        style={{ strokeDashoffset: on ? 0 : 1, transition: reduced ? "none" : `stroke-dashoffset ${on ? 280 : 140}ms ${EASE_OUT}` }}      />    </svg>  );}export function MenuList({ items, onClose, autoFocus = "none", label }: MenuListProps) {  const reduced = useInteractionReducedMotion();  const listRef = React.useRef<HTMLDivElement>(null);  const rows = items.filter((i): i is Row => "id" in i && !i.disabled);  const [active, setActive] = React.useState<string | null>(() => (autoFocus === "none" ? null : ((autoFocus === "first" ? rows[0] : rows[rows.length - 1])?.id ?? null)));  const [mark, setMark] = React.useState<{ y: number; h: number } | null>(null);  const [ready, setReady] = React.useState(false);  const typed = React.useRef({ text: "", at: 0 });  const focusRow = (id: string | null) => {    setActive(id);    if (id) listRef.current?.querySelector<HTMLElement>(`[data-menu-row="${CSS.escape(id)}"]`)?.focus({ preventScroll: true });  };  // On mount: focus the starting row (keyboard open) or the list itself.  React.useEffect(() => {    const list = listRef.current;    const row = active ? list?.querySelector<HTMLElement>(`[data-menu-row="${CSS.escape(active)}"]`) : null;    (row ?? list)?.focus({ preventScroll: true });    // eslint-disable-next-line react-hooks/exhaustive-deps  }, []);  React.useLayoutEffect(() => {    const list = listRef.current;    if (!list) return;    const measure = () => {      const el = active ? list.querySelector<HTMLElement>(`[data-menu-row="${CSS.escape(active)}"]`) : null;      setMark((p) => (el ? (p && p.y === el.offsetTop && p.h === el.offsetHeight ? p : { y: el.offsetTop, h: el.offsetHeight }) : null));    };    measure();    const ro = new ResizeObserver(measure);    ro.observe(list);    return () => ro.disconnect();  }, [active]);  React.useEffect(() => {    if (!mark || ready) return;    const frame = requestAnimationFrame(() => setReady(true));    return () => cancelAnimationFrame(frame);  }, [mark, ready]);  const pick = (row: Row) => {    if (row.type === "checkbox") return row.onCheckedChange(!row.checked);    onClose();    row.onSelect?.();  };  const onKeyDown = (e: React.KeyboardEvent) => {    const index = rows.findIndex((r) => r.id === active);    let next: Row | undefined;    if (e.key === "ArrowDown") next = rows[(index + 1) % rows.length];    else if (e.key === "ArrowUp") next = rows[(index - 1 + rows.length) % rows.length];    else if (e.key === "Home") next = rows[0];    else if (e.key === "End") next = rows[rows.length - 1];    else if ((e.key === "Enter" || e.key === " ") && index >= 0) {      e.preventDefault();      return pick(rows[index]);    } else if (e.key === "Tab") return onClose();    else if (e.key.length === 1 && /\S/.test(e.key)) {      const now = e.timeStamp;      typed.current = { text: (now - typed.current.at < 600 ? typed.current.text : "") + e.key.toLowerCase(), at: now };      next = rows.find((r) => r.label.toLowerCase().startsWith(typed.current.text));    }    if (!next) return;    e.preventDefault();    focusRow(next.id);  };  return (    <div ref={listRef} role="menu" aria-label={label} tabIndex={-1} onKeyDown={onKeyDown} onPointerLeave={() => setActive(null)} className="relative flex flex-col p-1 outline-none">      <span        aria-hidden="true"        className="pointer-events-none absolute inset-x-1 top-0 rounded-[10px] bg-accent"        style={{          transform: `translateY(${mark?.y ?? 0}px)`,          height: mark?.h ?? 0,          opacity: mark ? 1 : 0,          transition: reduced || !ready ? "opacity 120ms" : `transform 220ms ${EASE_OUT}, height 220ms ${EASE_OUT}, opacity 160ms ${EASE_OUT}`,        }}      />      {items.map((item, i) => {        if (item.type === "separator") return <div key={`s${i}`} role="separator" className="mx-2 my-1 h-px bg-border" />;        if (item.type === "label") return <div key={`l${i}`} className="px-2.5 pb-1 pt-2 text-[11.5px] font-medium text-muted-foreground">{item.label}</div>;        const row = item as Row;        const check = row.type === "checkbox";        const destructive = !check && "destructive" in row && row.destructive;        return (          <div            key={row.id}            data-menu-row={row.id}            role={check ? "menuitemcheckbox" : "menuitem"}            aria-checked={check ? row.checked : undefined}            aria-disabled={row.disabled || undefined}            tabIndex={-1}            onPointerMove={() => !row.disabled && active !== row.id && focusRow(row.id)}            onClick={() => !row.disabled && pick(row)}            className={`relative flex h-8 select-none items-center gap-2.5 rounded-[10px] px-2.5 text-[13px] outline-none ${              row.disabled ? "cursor-default opacity-40" : "cursor-pointer"            } ${destructive ? "text-red-600 dark:text-red-400" : active === row.id ? "text-foreground" : "text-foreground/85"}`}          >            {check ? (              <span className="flex size-4 items-center justify-center text-foreground">                <Tick on={row.checked} reduced={reduced} />              </span>            ) : (              row.icon && <span className={`flex size-4 items-center justify-center [&_svg]:size-3.5 ${destructive ? "" : "text-muted-foreground"}`}>{row.icon}</span>            )}            <span className="flex-1 truncate">{row.label}</span>            {!check && "shortcut" in row && row.shortcut && <kbd className="font-mono text-[11px] text-muted-foreground">{row.shortcut}</kbd>}          </div>        );      })}    </div>  );}export default MenuList;

Registry item: /r/dropdown-menu.json