Components

Getting started

Components144

More

Actions where you right-click: the menu grows from the exact point, a long press opens it on touch, and Shift+F10 from the keyboard.

Used forFile and asset gridsCanvas and boardsTablesText editors

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/context-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

  • File and asset grids

    Rename, move, delete on a right-click.

  • Canvas and boards

    Actions for the item under the pointer.

  • Tables

    Row actions without a "…" column.

  • Text editors

    Custom actions on a selection.

What it handles for you

A right-click menu that only works with a mouse leaves phones and keyboards out; this one has a way in for each.

Instead of wiring upRadix ContextMenulong-press handlingviewport collision
  1. Three ways in

    Right-click, long press (500ms, cancelled by movement) and Shift+F10.

  2. From the point

    Grows from where you clicked; flips and shifts to fit.

  3. Same rows as the dropdown

    Gliding highlight, typeahead and checkable rows.

  4. Callout suppressed

    iOS doesn't show its own menu on the long press.

Overview

Custom right-click menus often forget touch and the keyboard. This one opens at the pointer on right-click, after a 500ms long press on touch (the area dims slightly while it waits), and on Shift+F10 or the menu key. It grows from the exact point, stays inside the window, and right-clicking elsewhere moves it there.

States

waiting
On touch, the area eases to 0.985 and 80% while the long press counts down.
open
The menu rises from the point (320ms).
pick
It closes and focus returns to the area.

Usage

usage.tsx
import { ContextMenu } from "@/components/ui/context-menu";<ContextMenu items={[{ id: "open", label: "Open" }, { id: "delete", label: "Delete", destructive: true, onSelect: remove }]}>  <FileCard file={file} /></ContextMenu>

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

Props

PropTypeDescription
childrenReactNodeThe area with the menu.
itemsMenuEntry[]Same rows as the dropdown menu.
labelstringdefault "Actions"Accessible name of the menu.
onOpenChange(open) => voidOpened or closed.

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.
  • The area takes focus (tabIndex 0) so Shift+F10 can reach it.
  • Reduced motion: it fades; the area doesn't dim.

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/context-menu.tsx
"use client";import * as React from "react";import { createPortal } from "react-dom";import { enterStyle, themeVars, useDismiss, useFloating, usePresence } from "./floating";import { MenuList, type MenuEntry } from "./menu-list";import { useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * CONTEXT MENU: actions where you right-click * *   open     right-click, a long press on touch (500ms, the area *            dims a touch while it waits) or Shift+F10 / the menu *            key; the menu grows from the exact point *   again    right-clicking elsewhere while open moves it there *   place    it flips and shifts to stay inside the window * * The rows are the same as the dropdown menu's: a gliding * highlight, keys, typeahead, checkable rows. * ───────────────────────────────────────────────────────── */export type { MenuEntry } from "./menu-list";export interface ContextMenuProps {  /** The area that has the menu. */  children: React.ReactNode;  items: MenuEntry[];  label?: string;  onOpenChange?: (open: boolean) => void;  className?: string;}export function ContextMenu({ children, items, label = "Actions", onOpenChange, className = "" }: ContextMenuProps) {  const reduced = useInteractionReducedMotion();  const area = React.useRef<HTMLDivElement>(null);  const surface = React.useRef<HTMLDivElement>(null);  const [point, setPoint] = React.useState<{ x: number; y: number } | null>(null);  const [byKey, setByKey] = React.useState(false);  const [holding, setHolding] = React.useState(false);  const press = React.useRef<{ timer: number; x: number; y: number } | null>(null);  const open = point !== null;  const { mounted, shown } = usePresence(open, 180);  const place = useFloating({ open: mounted, point, floating: surface, side: "bottom", offset: 2 });  const [vars, setVars] = React.useState<React.CSSProperties>({});  const openAt = (x: number, y: number, keyboard = false) => {    setByKey(keyboard);    setPoint({ x, y });    onOpenChange?.(true);  };  const close = (refocus = false) => {    setPoint(null);    onOpenChange?.(false);    if (refocus) area.current?.focus();  };  useDismiss(open, (reason) => close(reason === "escape"), [surface]);  React.useLayoutEffect(() => {    if (mounted) setVars(themeVars(area.current));  }, [mounted]);  const cancelPress = () => {    if (press.current) window.clearTimeout(press.current.timer);    press.current = null;    setHolding(false);  };  return (    <>      <div        ref={area}        tabIndex={0}        aria-haspopup="menu"        onContextMenu={(e) => {          e.preventDefault();          cancelPress();          openAt(e.clientX, e.clientY);        }}        onKeyDown={(e) => {          if ((e.shiftKey && e.key === "F10") || e.key === "ContextMenu") {            e.preventDefault();            const r = e.currentTarget.getBoundingClientRect();            openAt(r.left + 16, r.top + 16, true);          }        }}        onPointerDown={(e) => {          if (e.pointerType === "mouse") return;          const { clientX: x, clientY: y } = e;          setHolding(true);          press.current = { x, y, timer: window.setTimeout(() => {            setHolding(false);            openAt(x, y);          }, 500) };        }}        onPointerMove={(e) => {          const p = press.current;          if (p && Math.hypot(e.clientX - p.x, e.clientY - p.y) > 8) cancelPress();        }}        onPointerUp={cancelPress}        onPointerCancel={cancelPress}        className={`outline-none transition-[scale,opacity] duration-500 ease-out focus-visible:ring-2 focus-visible:ring-ring/40 ${holding && !reduced ? "scale-[0.985] opacity-80" : ""} ${className}`}        style={{ WebkitTouchCallout: "none" }}      >        {children}      </div>      {mounted &&        createPortal(          <div            ref={surface}            className="fixed left-0 top-0 z-[55] min-w-[200px] overflow-hidden rounded-[14px] bg-popover text-popover-foreground shadow-[0_0_0_1px_var(--border)]"            style={{ ...vars, left: place.x, top: place.y, transformOrigin: "0 0", ...enterStyle(shown, place.side, reduced) }}          >            <MenuList key={`${point?.x}-${point?.y}`} items={items} label={label} autoFocus={byKey ? "first" : "none"} onClose={() => close(true)} />          </div>,          document.body,        )}    </>  );}export default ContextMenu;
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/context-menu.json