Context menu
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
$ npxnpx shadcn@latest add https://hairlineui.com/r/context-menu.jsonTemplates
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.
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.
- 01
Three ways in
Right-click, long press (500ms, cancelled by movement) and Shift+F10.
- 02
From the point
Grows from where you clicked; flips and shifts to fit.
- 03
Same rows as the dropdown
Gliding highlight, typeahead and checkable rows.
- 04
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
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
| Prop | Type | Description |
|---|---|---|
| children | ReactNode | The area with the menu. |
| items | MenuEntry[] | Same rows as the dropdown menu. |
| label | stringdefault "Actions" | Accessible name of the menu. |
| onOpenChange | (open) => void | Opened 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
"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;"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}`, };}"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


