ATOM
Components (registry)Navigation

ContextMenu

Right-click (or long-press) and a menu appears at the pointer. Row actions without cluttering the UI.

Preview

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>
ClasePropósito
context-menuroot
context-menu__contentelement
context-menu__triggerelement

Tokens resueltos

Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.

VariantePropValorToken
allbg#ffffffpopover
allfg#0a0a0apopover.foreground
allborder1pxstroke.hairline

Animaciones

PropiedadDuraciónEasing
@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

components-react
components/atoms/ContextMenu.tsx
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

Webflowcontext-menu

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)

  • @media on (prefers-reduced-motion: reduce)not a Designer breakpoint — moved to head Custom Code block

Tras pegar: Create component → nombre Atom / ContextMenu → Publish.

Componentes relacionados

On this page

Detalles

Publicado14 de mayo de 2026
Categorianavigation
Lectura...
Visitas...
Ayuda?Slack

Componente

ContextMenu

Source

components-react / cssDisponible via MCP: atom_uikit_source("context-menu")
Abrir en Storybook