Context Menu
A right-click menu with items, shortcuts and keyboard navigation.
NewRight click here
1'use client';23import { ContextMenu } from '@/components/ui/context-menu';4import { CopyIcon } from '@/components/ui/copy';5import { DownloadIcon } from '@/components/ui/download';6import { PencilIcon } from '@/components/ui/pencil';7import { ShareIcon } from '@/components/ui/share';8import { TrashIcon } from '@/components/ui/trash';910export function Default() {11 return (12 <ContextMenu className="w-full max-w-md">13 <ContextMenu.Trigger className="border-border bg-hatch text-muted-foreground flex h-48 w-full items-center justify-center rounded-xl border text-sm">14 Right click here15 </ContextMenu.Trigger>16 <ContextMenu.Content>17 <ContextMenu.Label>report-q3.pdf</ContextMenu.Label>18 <ContextMenu.Separator />19 <ContextMenu.Item>20 <PencilIcon size={16} />21 Rename22 <ContextMenu.Shortcut>F2</ContextMenu.Shortcut>23 </ContextMenu.Item>24 <ContextMenu.Item>25 <CopyIcon size={16} />26 Copy27 <ContextMenu.Shortcut>Ctrl C</ContextMenu.Shortcut>28 </ContextMenu.Item>29 <ContextMenu.Item>30 <DownloadIcon size={16} />31 Download32 </ContextMenu.Item>33 <ContextMenu.Item disabled>34 <ShareIcon size={16} />35 Share36 </ContextMenu.Item>37 <ContextMenu.Separator />38 <ContextMenu.Item variant="destructive">39 <TrashIcon size={16} />40 Delete41 <ContextMenu.Shortcut>Del</ContextMenu.Shortcut>42 </ContextMenu.Item>43 </ContextMenu.Content>44 </ContextMenu>45 );46}
Installation
Copy and paste the following code into your project.
1'use client';23import { AnimatePresence, motion, useReducedMotion } from 'motion/react';4import * as React from 'react';5import { cn } from '../lib/cn';6import { FloatingPortal, useFloating } from '../lib/floating';7import { floatingVariants, reveal } from '../lib/motion';8import { useLayer } from '../lib/overlay';9import { useControllableState } from '../lib/use-controllable-state';1011interface ContextMenuContextValue {12 isOpen: boolean;13 point: { x: number; y: number } | null;14 openAt: (x: number, y: number) => void;15 closeMenu: (options?: { restoreFocus?: boolean }) => void;16 triggerRef: React.RefObject<HTMLDivElement | null>;17 triggerId: string;18 contentId: string;19}2021interface ContextMenuProps {22 children: React.ReactNode;23 className?: string;24 open?: boolean;25 defaultOpen?: boolean;26 onOpenChange?: (open: boolean) => void;27}2829interface ContextMenuTriggerProps extends React.HTMLAttributes<HTMLDivElement> {30 children: React.ReactNode;31 disabled?: boolean;32}3334interface ContextMenuContentProps {35 children: React.ReactNode;36 className?: string;37}3839interface ContextMenuItemProps {40 children: React.ReactNode;41 onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;42 className?: string;43 disabled?: boolean;44 onSelect?: () => void;45 variant?: 'default' | 'destructive';46}4748const CONTEXT_MENU_STYLE = { willChange: 'opacity, transform, filter' } as const;4950const LONG_PRESS_MS = 500;51const LONG_PRESS_TOLERANCE = 10;52const TYPEAHEAD_RESET_MS = 500;5354const ContextMenuContext = React.createContext<ContextMenuContextValue | null>(null);5556const useContextMenuContext = (): ContextMenuContextValue => {57 const context = React.use(ContextMenuContext);58 if (!context) throw new Error('ContextMenu components must be used within ContextMenu');59 return context;60};6162function getMenuItems(container: HTMLElement | null): HTMLElement[] {63 if (!container) return [];64 return Array.from(65 container.querySelectorAll<HTMLElement>('[role="menuitem"]:not([aria-disabled="true"])'),66 );67}6869function useTypeahead() {70 const bufferRef = React.useRef('');71 const timerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);7273 React.useEffect(() => () => clearTimeout(timerRef.current), []);7475 const isTyping = React.useCallback(() => bufferRef.current !== '', []);7677 const search = React.useCallback((key: string, items: HTMLElement[], current: number) => {78 clearTimeout(timerRef.current);79 timerRef.current = setTimeout(() => {80 bufferRef.current = '';81 }, TYPEAHEAD_RESET_MS);82 const buffer = bufferRef.current + key.toLowerCase();83 bufferRef.current = buffer;84 const repeated = Array.from(buffer).every((char) => char === buffer[0]);85 const query = repeated ? buffer.charAt(0) : buffer;86 const start = repeated ? current + 1 : Math.max(current, 0);87 const ordered = [...items.slice(start), ...items.slice(0, start)];88 return ordered.find((item) => item.textContent?.trim().toLowerCase().startsWith(query));89 }, []);9091 return React.useMemo(() => ({ search, isTyping }), [search, isTyping]);92}9394const ContextMenuRoot = ({95 children,96 className,97 open,98 defaultOpen = false,99 onOpenChange,100}: ContextMenuProps): React.JSX.Element => {101 const [isOpen, setIsOpen] = useControllableState({102 value: open,103 defaultValue: defaultOpen,104 onChange: onOpenChange,105 });106 const [point, setPoint] = React.useState<{ x: number; y: number } | null>(null);107 const triggerRef = React.useRef<HTMLDivElement>(null);108109 const openAt = React.useCallback(110 (x: number, y: number) => {111 setPoint({ x, y });112 setIsOpen(true);113 },114 [setIsOpen],115 );116117 const closeMenu = React.useCallback(118 (options?: { restoreFocus?: boolean }) => {119 setIsOpen(false);120 if (options?.restoreFocus !== false) {121 triggerRef.current?.focus();122 }123 },124 [setIsOpen],125 );126127 const id = React.useId();128 const triggerId = `context-menu-trigger-${id}`;129 const contentId = `context-menu-content-${id}`;130131 const contextValue = React.useMemo(132 () => ({ isOpen, point, openAt, closeMenu, triggerRef, triggerId, contentId }),133 [isOpen, point, openAt, closeMenu, triggerId, contentId],134 );135136 return (137 <ContextMenuContext value={contextValue}>138 <div className={cn('relative', className)}>{children}</div>139 </ContextMenuContext>140 );141};142143const ContextMenuTrigger = ({144 children,145 className,146 disabled = false,147 onContextMenu,148 onKeyDown,149 ...props150}: ContextMenuTriggerProps): React.JSX.Element => {151 const { isOpen, openAt, triggerRef, triggerId } = useContextMenuContext();152 const longPressTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);153 const longPressStart = React.useRef<{ x: number; y: number } | null>(null);154155 const clearLongPress = React.useCallback(() => {156 if (longPressTimer.current !== null) {157 clearTimeout(longPressTimer.current);158 longPressTimer.current = null;159 }160 }, []);161162 React.useEffect(() => clearLongPress, [clearLongPress]);163164 const handleContextMenu = React.useCallback(165 (e: React.MouseEvent<HTMLDivElement>) => {166 onContextMenu?.(e);167 if (disabled) return;168 e.preventDefault();169 openAt(e.clientX, e.clientY);170 },171 [disabled, openAt, onContextMenu],172 );173174 const handleTouchStart = React.useCallback(175 (e: React.TouchEvent<HTMLDivElement>) => {176 if (disabled) return;177 const touch = e.touches[0];178 if (!touch) return;179 const { clientX, clientY } = touch;180 clearLongPress();181 longPressStart.current = { x: clientX, y: clientY };182 longPressTimer.current = setTimeout(() => openAt(clientX, clientY), LONG_PRESS_MS);183 },184 [disabled, openAt, clearLongPress],185 );186187 const handleTouchMove = React.useCallback(188 (e: React.TouchEvent<HTMLDivElement>) => {189 const start = longPressStart.current;190 const touch = e.touches[0];191 if (!start || !touch) return;192 const distance = Math.hypot(touch.clientX - start.x, touch.clientY - start.y);193 if (distance > LONG_PRESS_TOLERANCE) clearLongPress();194 },195 [clearLongPress],196 );197198 const handleKeyDown = React.useCallback(199 (e: React.KeyboardEvent<HTMLDivElement>) => {200 onKeyDown?.(e);201 if (disabled) return;202 if ((e.shiftKey && e.key === 'F10') || e.key === 'ContextMenu') {203 e.preventDefault();204 const rect = triggerRef.current?.getBoundingClientRect();205 if (rect) openAt(rect.left + rect.width / 2, rect.top + rect.height / 2);206 }207 },208 [disabled, openAt, triggerRef, onKeyDown],209 );210211 return (212 <div213 ref={triggerRef}214 id={triggerId}215 tabIndex={disabled ? undefined : 0}216 aria-keyshortcuts={disabled ? undefined : 'Shift+F10 ContextMenu'}217 data-state={isOpen ? 'open' : 'closed'}218 onContextMenu={handleContextMenu}219 onTouchStart={handleTouchStart}220 onTouchEnd={clearLongPress}221 onTouchCancel={clearLongPress}222 onTouchMove={handleTouchMove}223 onKeyDown={handleKeyDown}224 className={cn(225 'ring-offset-background focus-visible:ring-ring select-none [-webkit-touch-callout:none] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',226 className,227 )}228 {...props}229 >230 {children}231 </div>232 );233};234235const ContextMenuContent = ({236 children,237 className,238}: ContextMenuContentProps): React.JSX.Element | null => {239 const { isOpen, point, closeMenu, triggerRef, contentId, triggerId } = useContextMenuContext();240 const contentRef = React.useRef<HTMLDivElement>(null);241 const layer = useLayer(isOpen, false, contentRef);242 const typeahead = useTypeahead();243 const shouldReduceMotion = useReducedMotion();244 const { style: floatingStyle, side } = useFloating({245 open: isOpen,246 anchorRef: triggerRef,247 floatingRef: contentRef,248 point,249 side: 'bottom',250 align: 'start',251 sideOffset: point ? 0 : 4,252 });253 const verticalSide = side === 'top' ? 'top' : 'bottom';254255 React.useEffect(() => {256 if (!isOpen) return;257 const dismiss = () => closeMenu({ restoreFocus: false });258 const handlePointerDown = (event: PointerEvent) => {259 if (contentRef.current?.contains(event.target as Node)) return;260 if (layer.isInsideUpperLayer(event.target)) return;261 dismiss();262 };263 const handleScroll = (event: Event) => {264 if (event.target instanceof Node && contentRef.current?.contains(event.target)) return;265 dismiss();266 };267 document.addEventListener('pointerdown', handlePointerDown);268 window.addEventListener('scroll', handleScroll, { capture: true, passive: true });269 window.addEventListener('resize', dismiss);270 window.addEventListener('blur', dismiss);271 return () => {272 document.removeEventListener('pointerdown', handlePointerDown);273 window.removeEventListener('scroll', handleScroll, { capture: true });274 window.removeEventListener('resize', dismiss);275 window.removeEventListener('blur', dismiss);276 };277 }, [isOpen, closeMenu, layer]);278279 React.useEffect(() => {280 if (!isOpen) return;281 const frame = requestAnimationFrame(() => {282 getMenuItems(contentRef.current)[0]?.focus({ preventScroll: true });283 });284 return () => cancelAnimationFrame(frame);285 }, [isOpen]);286287 const handleKeyDown = React.useCallback(288 (e: React.KeyboardEvent<HTMLDivElement>) => {289 if (e.key === 'Escape') {290 if (!layer.isTopmost()) return;291 e.preventDefault();292 closeMenu();293 return;294 }295 if (e.key === 'Tab') {296 closeMenu();297 return;298 }299300 const items = getMenuItems(contentRef.current);301 if (items.length === 0) return;302303 const currentIndex = items.indexOf(document.activeElement as HTMLElement);304305 let nextIndex: number | null = null;306 if (e.key === 'ArrowDown') {307 nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % items.length;308 } else if (e.key === 'ArrowUp') {309 nextIndex =310 currentIndex < 0 ? items.length - 1 : (currentIndex - 1 + items.length) % items.length;311 } else if (e.key === 'Home') {312 nextIndex = 0;313 } else if (e.key === 'End') {314 nextIndex = items.length - 1;315 } else if (316 e.key.length === 1 &&317 !e.altKey &&318 !e.ctrlKey &&319 !e.metaKey &&320 (e.key !== ' ' || typeahead.isTyping())321 ) {322 e.preventDefault();323 e.stopPropagation();324 typeahead.search(e.key, items, currentIndex)?.focus();325 return;326 }327328 if (nextIndex !== null) {329 e.preventDefault();330 items[nextIndex]?.focus();331 }332 },333 [closeMenu, layer, typeahead],334 );335336 const style = React.useMemo(337 () => ({338 ...floatingStyle,339 ...(isOpen ? null : { visibility: undefined }),340 ...CONTEXT_MENU_STYLE,341 }),342 [floatingStyle, isOpen],343 );344345 return (346 <FloatingPortal>347 <AnimatePresence>348 {isOpen && (349 <motion.div350 ref={contentRef}351 id={contentId}352 role="menu"353 tabIndex={-1}354 aria-labelledby={triggerId}355 onKeyDownCapture={handleKeyDown}356 onContextMenu={(event: React.MouseEvent) => event.preventDefault()}357 variants={shouldReduceMotion ? reveal : floatingVariants[verticalSide]}358 initial="hidden"359 animate="visible"360 exit="exit"361 style={style}362 className={cn(363 'bg-popover text-popover-foreground border-border z-500 max-h-(--available-height) min-w-48 overflow-y-auto overscroll-contain rounded-md border shadow-md outline-none',364 verticalSide === 'top' ? 'origin-bottom-left' : 'origin-top-left',365 className,366 )}367 >368 <div className="flex flex-col gap-0.5 p-1">{children}</div>369 </motion.div>370 )}371 </AnimatePresence>372 </FloatingPortal>373 );374};375376const itemClassName = (disabled: boolean, variant: 'default' | 'destructive') =>377 cn(378 'relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none',379 'transition-colors duration-150',380 'focus-visible:bg-muted focus:bg-muted pointer-coarse:py-2.5',381 disabled && 'pointer-events-none opacity-40',382 variant === 'destructive' && 'text-destructive focus:text-destructive',383 );384385const ContextMenuItem = ({386 children,387 onClick,388 className,389 disabled = false,390 variant = 'default',391 onSelect,392}: ContextMenuItemProps): React.JSX.Element => {393 const { closeMenu } = useContextMenuContext();394395 const handleClick = React.useCallback(396 (e: React.MouseEvent<HTMLDivElement>) => {397 if (disabled) return;398 e.stopPropagation();399 onClick?.(e);400 onSelect?.();401 closeMenu();402 },403 [disabled, onClick, onSelect, closeMenu],404 );405406 const handleKeyDown = React.useCallback(407 (e: React.KeyboardEvent<HTMLDivElement>) => {408 if (disabled) return;409 if (e.key === 'Enter' || e.key === ' ') {410 e.preventDefault();411 e.stopPropagation();412 onClick?.(e as unknown as React.MouseEvent<HTMLDivElement>);413 onSelect?.();414 closeMenu();415 }416 },417 [disabled, onClick, onSelect, closeMenu],418 );419420 const handlePointerMove = React.useCallback(421 (e: React.PointerEvent<HTMLDivElement>) => {422 if (disabled || e.pointerType === 'touch') return;423 if (document.activeElement !== e.currentTarget) e.currentTarget.focus();424 },425 [disabled],426 );427428 const handlePointerLeave = React.useCallback((e: React.PointerEvent<HTMLDivElement>) => {429 if (document.activeElement !== e.currentTarget) return;430 e.currentTarget.closest<HTMLElement>('[role="menu"]')?.focus();431 }, []);432433 return (434 <div435 role="menuitem"436 tabIndex={-1}437 aria-disabled={disabled || undefined}438 onClick={handleClick}439 onKeyDown={handleKeyDown}440 onPointerMove={handlePointerMove}441 onPointerLeave={handlePointerLeave}442 className={cn(itemClassName(disabled, variant), className)}443 >444 {children}445 </div>446 );447};448449const ContextMenuLabel = ({450 children,451 className,452}: {453 children: React.ReactNode;454 className?: string;455}) => {456 return (457 <div458 role="presentation"459 className={cn(460 'text-muted-foreground px-2 py-2 text-xs font-semibold tracking-wider uppercase',461 className,462 )}463 >464 {children}465 </div>466 );467};468469const ContextMenuSeparator = ({ className }: { className?: string }) => {470 return (471 <div472 role="separator"473 aria-orientation="horizontal"474 className={cn('bg-border/50 my-1 h-px', className)}475 />476 );477};478479const ContextMenuShortcut = ({480 children,481 className,482}: {483 children: React.ReactNode;484 className?: string;485}) => {486 return (487 <span className={cn('text-muted-foreground ml-auto pl-6 text-xs tracking-widest', className)}>488 {children}489 </span>490 );491};492493const ContextMenu = Object.assign(ContextMenuRoot, {494 Trigger: ContextMenuTrigger,495 Content: ContextMenuContent,496 Item: ContextMenuItem,497 Label: ContextMenuLabel,498 Separator: ContextMenuSeparator,499 Shortcut: ContextMenuShortcut,500});501502export { ContextMenu };503export type {504 ContextMenuContentProps,505 ContextMenuItemProps,506 ContextMenuProps,507 ContextMenuTriggerProps,508};
Update the import paths to match your project structure.
Anatomy
1import { ContextMenu } from '@/components/ui/context-menu';
1<ContextMenu>2 <ContextMenu.Trigger>Right click here</ContextMenu.Trigger>3 <ContextMenu.Content>4 <ContextMenu.Label>Actions</ContextMenu.Label>5 <ContextMenu.Separator />6 <ContextMenu.Item>7 Copy8 <ContextMenu.Shortcut>Ctrl C</ContextMenu.Shortcut>9 </ContextMenu.Item>10 <ContextMenu.Item variant="destructive">Delete</ContextMenu.Item>11 </ContextMenu.Content>12</ContextMenu>
The menu opens at the pointer on right click, on long press on touch devices, and from the keyboard with Shift F10 or the context menu key. It renders in a portal, so a parent with
overflow: hidden never clips it, it stays inside the viewport, and it closes when the page scrolls or the window loses focus. Arrow keys, Home, End and Escape work as in any menu, and typing the first letters of an item jumps to it.Composition
Use the following composition to build a
ContextMenu:ContextMenuโโโ ContextMenu.Triggerโโโ ContextMenu.Contentโโโ ContextMenu.Labelโโโ ContextMenu.Itemโ โโโ ContextMenu.Shortcutโโโ ContextMenu.Separator
API Reference
ContextMenu
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | - | Controlled open state |
defaultOpen | boolean | false | Open state on first render when uncontrolled |
onOpenChange | (open: boolean) => void | - | Callback when open state changes |
className | string | - | Additional CSS classes |
ContextMenu.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Ignore right clicks and long press |
className | string | - | Additional CSS classes |
ContextMenu.Item
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Disable the item |
variant | 'default' | 'destructive' | 'default' | Visual style |
onSelect | () => void | - | Callback when selected |
className | string | - | Additional CSS classes |
ContextMenu.Label
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Label text |
ContextMenu.Shortcut
Right-aligned hint inside an item. Purely visual, it does not register the shortcut.
ContextMenu.Separator
Visual separator between menu items. No props.
Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.