Context Menu
Un menú de clic derecho con acciones, atajos y navegación por teclado.
NuevoRight 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}
Instalación
Copia y pega el siguiente código en tu proyecto.
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};
Actualiza las rutas de importación para que coincidan con la estructura de tu proyecto.
Anatomía
1import { ContextMenu } from '@/components/ui/context-menu';
1<ContextMenu>2 <ContextMenu.Trigger>Haz clic derecho aquí</ContextMenu.Trigger>3 <ContextMenu.Content>4 <ContextMenu.Label>Acciones</ContextMenu.Label>5 <ContextMenu.Separator />6 <ContextMenu.Item>7 Copiar8 <ContextMenu.Shortcut>Ctrl C</ContextMenu.Shortcut>9 </ContextMenu.Item>10 <ContextMenu.Item variant="destructive">Eliminar</ContextMenu.Item>11 </ContextMenu.Content>12</ContextMenu>
El menú se abre en el puntero con clic derecho, con pulsación larga en pantallas táctiles, y desde el teclado con Shift F10 o la tecla de menú contextual. Se renderiza en un portal, así que un padre con
overflow: hidden nunca lo recorta, se mantiene dentro del viewport y se cierra cuando la página hace scroll o la ventana pierde el foco. Las flechas, Home, End y Escape funcionan como en cualquier menú, y escribir las primeras letras de un item lo enfoca.Composición
Usa la siguiente composición para construir un
ContextMenu:ContextMenu├── ContextMenu.Trigger└── ContextMenu.Content├── ContextMenu.Label├── ContextMenu.Item│ └── ContextMenu.Shortcut└── ContextMenu.Separator
Referencia de API
ContextMenu
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | - | Estado abierto controlado |
defaultOpen | boolean | false | Estado abierto inicial cuando no está controlado |
onOpenChange | (open: boolean) => void | - | Callback cuando cambia el estado abierto |
className | string | - | Clases CSS adicionales |
ContextMenu.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Ignora el clic derecho y la pulsación larga |
className | string | - | Clases CSS adicionales |
ContextMenu.Item
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Deshabilita el elemento |
variant | 'default' | 'destructive' | 'default' | Estilo visual |
onSelect | () => void | - | Callback al seleccionar |
className | string | - | Clases CSS adicionales |
ContextMenu.Label
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Texto de etiqueta |
ContextMenu.Shortcut
Pista alineada a la derecha dentro de un elemento. Es solo visual, no registra el atajo.
ContextMenu.Separator
Separador visual entre elementos del menú. Sin props.
¿Encontraste algo que mejorar?
¿Notaste un error, tipografía o detalle faltante en esta página? Ayúdanos a mejorar la documentación abriendo un issue en GitHub.