ContextMenu
Right-click (or long-press) and a menu appears at the pointer. Row actions without cluttering the UI.
Variants: defaultdestructive (default: default)
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Row actions on right-click:
```tsx import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, } from '@/components/atoms/ContextMenu';
<ContextMenu> <ContextMenuTrigger>Invoice #1042</ContextMenuTrigger> <ContextMenuContent> <ContextMenuItem onSelect={() => {}}>Open</ContextMenuItem> <ContextMenuItem variant="destructive" onSelect={() => {}}>Delete</ContextMenuItem> </ContextMenuContent> </ContextMenu> ```
## Accesibilidad
- Items use `role="menuitem"` — keep short labels; destructive actions need clear wording. - Primary actions should also be reachable without right-click (row button or `DropdownMenu`) for keyboard/pointer diversity.
## Cuándo no usar
- Always-visible primary actions → toolbar `Button` / `DropdownMenu` on a trigger. - Mobile-first UIs where long-press is unreliable — prefer an explicit overflow menu.
## Criterio de uso
- Usa ContextMenu solo como ATAJO de acciones que tambien existen en la interfaz visible: es descubrible por accidente, nunca por diseno. - Util en superficies densas (tablas, canvas, listas) donde poner un boton por fila satura. - Repite en el menu la accion primaria de la fila para que quien lo abre no tenga que salir a buscarla.
## Gotchas
- No tiene prop `open`: el disparador es el click derecho del navegador, y esa es toda su superficie de activacion. - En tactil no existe el click derecho: si una accion solo vive aqui, en movil es inalcanzable.
Uso
import {
ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem,
} from '@/components/atoms/ContextMenu';
<ContextMenu>
<ContextMenuTrigger>Right-click me</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => {}}>Copy</ContextMenuItem>
<ContextMenuItem variant="destructive" onSelect={() => {}}>Delete</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>Props
children: ReactNode (required)
Gotchas
- react
Compose ContextMenu > ContextMenuTrigger + ContextMenuContent + ContextMenuItem. Right-click opens; Escape and outside click close.
- a11y
Items use role=menuitem; keep labels short and prefer keyboard-accessible DropdownMenu for primary actions.
- layout
Content is position:fixed at cursor coords — ensure a stacking context and room near the viewport edge.
Anatomía CSS
<div class="context-menu"> <span class="context-menu__content"></span> <span class="context-menu__trigger"></span> </div>
| Clase | Propósito |
|---|---|
context-menu | root |
context-menu__content | element |
context-menu__trigger | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | bg | #ffffff | popover |
| all | fg | #0a0a0a | popover.foreground |
| all | border | 1px | stroke.hairline |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
@keyframes context-menu-in | | |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Context Menu
Right-click menu. Same visual as DropdownMenu, different trigger.
Positioned at mouse coordinates, not relative to trigger element.
Parts: .context-menu, .context-menu__trigger, .context-menu__content
Item styling reuses dropdown-menu classes for consistency.
------------------------------------------------------------------------- */
/* ---- Trigger zone ---- */
.context-menu__trigger {
display: contents;
}
/* ---- Content (positioned at mouse coords via inline style) ---- */
.context-menu__content {
position: fixed;
z-index: 20;
min-width: 192px;
overflow: hidden;
border: 1px solid #e5e5e5;
border-radius: 12px;
background-color: #ffffff;
color: #0a0a0a;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
padding: 4px;
animation: context-menu-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes context-menu-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.context-menu__content {
animation-duration: 0ms;
}
}
Codigo fuente
import { type ReactNode, type KeyboardEvent, type MouseEvent as ReactMouseEvent, useState, useRef, useEffect, useCallback, createContext, useContext, } from 'react'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } /* ---- Context ---- */ type ContextMenuContextValue = { open: boolean; setOpen: (v: boolean) => void; position: { x: number; y: number }; }; const ContextMenuContext = createContext<ContextMenuContextValue | null>(null); function useContextMenu() { const ctx = useContext(ContextMenuContext); if (!ctx) throw new Error('ContextMenu components must be used within <ContextMenu>'); return ctx; } /* ---- Root ---- */ export type ContextMenuProps = { children: ReactNode; className?: string; }; export function ContextMenu({ children, className }: ContextMenuProps) { const [open, setOpenRaw] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); const rootRef = useRef<HTMLDivElement>(null); const setOpen = useCallback((v: boolean) => { setOpenRaw(v); }, []); const handleContextMenu = (e: ReactMouseEvent) => { e.preventDefault(); setPosition({ x: e.clientX, y: e.clientY }); setOpen(true); }; useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [open, setOpen]); useEffect(() => { if (!open) return; const handler = (e: globalThis.KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [open, setOpen]); return ( <ContextMenuContext.Provider value={{ open, setOpen, position }}> <div ref={rootRef} className={className} onContextMenu={handleContextMenu}> {children} </div> </ContextMenuContext.Provider> ); } /* ---- Trigger (zone that receives right-click) ---- */ export function ContextMenuTrigger({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('context-menu__trigger', className)}>{children}</div>; } /* ---- Content ---- */ export function ContextMenuContent({ children, className }: { children: ReactNode; className?: string }) { const { open, position } = useContextMenu(); if (!open) return null; return ( <div role="menu" className={cn('context-menu__content', className)} style={{ top: position.y, left: position.x }} > {children} </div> ); } /* ---- Item (reuses dropdown-menu styling) ---- */ export type ContextMenuItemProps = { variant?: 'default' | 'destructive'; disabled?: boolean; onSelect?: () => void; children: ReactNode; className?: string; }; export function ContextMenuItem({ variant = 'default', disabled = false, onSelect, children, className, }: ContextMenuItemProps) { const { setOpen } = useContextMenu(); const handleClick = () => { if (disabled) return; onSelect?.(); setOpen(false); }; const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(); } }; return ( <div role="menuitem" tabIndex={disabled ? -1 : 0} aria-disabled={disabled || undefined} className={cn( 'dropdown-menu__item', variant === 'destructive' && 'dropdown-menu__item--destructive', disabled && 'dropdown-menu__item--disabled', className, )} onClick={handleClick} onKeyDown={handleKeyDown} > {children} </div> ); } /* ---- Label ---- */ export function ContextMenuLabel({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('dropdown-menu__label', className)}>{children}</div>; } /* ---- Shortcut ---- */ export function ContextMenuShortcut({ children, className }: { children: ReactNode; className?: string }) { return <span className={cn('dropdown-menu__shortcut', className)}>{children}</span>; } /* ---- Separator ---- */ export function ContextMenuSeparator({ className }: { className?: string }) { return <div role="separator" className={cn('dropdown-menu__separator', className)} />; }
Webflow
Pega en el Designer como application/json, luego convierte a Component (Atom / ContextMenu) y publica. Formato interno no documentado de Webflow — regenerable, no dependencia de runtime.
Setup del sitio (una vez)
Custom Code → Head:
<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/tokens.css"> <link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/components.css">
Unsupported (no silencioso)
@mediaon(prefers-reduced-motion: reduce)— not a Designer breakpoint — moved to head Custom Code block
Tras pegar: Create component → nombre Atom / ContextMenu → Publish.