ATOM
Components (registry)Navigation

DropdownMenu

Click a trigger and a menu drops with actions or nested groups. App chrome and overflow menus.

Preview

Variants: defaultdestructive (default: default)

Editorial

<!-- F12c editorial — non-derivable only. Review: Karen. -->

## Ejemplos

Overflow actions on a trigger:

```tsx import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from '@/components/atoms/DropdownMenu';

<DropdownMenu> <DropdownMenuTrigger>Open</DropdownMenuTrigger> <DropdownMenuContent side="bottom" align="start"> <DropdownMenuItem onSelect={() => {}}>Edit</DropdownMenuItem> <DropdownMenuItem variant="destructive" onSelect={() => {}}>Delete</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ```

## Accesibilidad

- Trigger exposes `aria-expanded` and `aria-haspopup="menu"`; keep items activatable with Enter/Space. - Controlled mode needs `open` + `onOpenChange` together.

## Cuándo no usar

- Always-visible primary actions → toolbar `Button`s. - Right-click-only row menus → `ContextMenu` (still offer a visible alternative).

## Criterio de uso

- Usa DropdownMenu para acciones sobre un objeto, no para elegir un valor de formulario — para eso existe `Select`. - Agrupa por consecuencia y deja las destructivas al final, separadas; el orden importa mas que el icono. - Cierra el menu al ejecutar una accion salvo que sea un toggle que el usuario querra repetir.

## Gotchas

- Es controlado: si `onOpenChange` no actualiza el estado, el menu queda abierto tras seleccionar. - Un item que abre otro overlay debe cerrar el menu primero; encadenarlos deja dos capas compitiendo por el foco.

Uso

import {
  DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
} from '@/components/atoms/DropdownMenu';

<DropdownMenu>
  <DropdownMenuTrigger>Open</DropdownMenuTrigger>
  <DropdownMenuContent side="bottom" align="start">
    <DropdownMenuItem onSelect={() => {}}>Edit</DropdownMenuItem>
    <DropdownMenuItem variant="destructive" onSelect={() => {}}>Delete</DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

Props

PropTipoDefaultRango / opcionesWhatHow
openboolean`true` / `false`Controlled open state of the menu.Omit for uncontrolled (internal state). Pass open + onOpenChange when a parent must sync (toolbars, command palettes).

Gotchas

  • react

    Controlled: open + onOpenChange together. Content side/align live on DropdownMenuContent (bottom/start defaults), not on the root.

  • a11y

    Trigger exposes aria-expanded and aria-haspopup=menu; keep menu items keyboard Enter/Space activatable.

Anatomía CSS

<div class="dropdown-menu">
  <span class="dropdown-menu__content"></span>
  <span class="dropdown-menu__content--align-end"></span>
  <span class="dropdown-menu__content--align-start"></span>
  <span class="dropdown-menu__content--bottom"></span>
  <span class="dropdown-menu__content--top"></span>
  <span class="dropdown-menu__item"></span>
  <span class="dropdown-menu__item--destructive"></span>
  <span class="dropdown-menu__item--disabled"></span>
  <span class="dropdown-menu__item-icon"></span>
  <span class="dropdown-menu__label"></span>
  <span class="dropdown-menu__separator"></span>
  <span class="dropdown-menu__shortcut"></span>
  <span class="dropdown-menu__trigger"></span>
</div>
ClasePropósito
dropdown-menuroot
dropdown-menu__contentelement
dropdown-menu__content--align-endelement
dropdown-menu__content--align-startelement
dropdown-menu__content--bottomelement
dropdown-menu__content--topelement
dropdown-menu__itemelement
dropdown-menu__item--destructiveelement
dropdown-menu__item--disabledelement
dropdown-menu__item-iconelement
dropdown-menu__labelelement
dropdown-menu__separatorelement
dropdown-menu__shortcutelement
dropdown-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#e5e5e5border
allfg#525252muted.foreground
allbordernone(unparsed)
allhover-bg#f84131destructive
allhover-fg#0a0a0adestructive.foreground

Animaciones

PropiedadDuraciónEasing
background-colorvar(--duration-100)var(--easing-in-out)
@keyframes dropdown-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).

/* -------------------------------------------------------------------------
   Dropdown Menu

   Action menu triggered by a button. Floating panel with items.
   Composable: Trigger + Content > Label + Group + Item + Separator

   Parts: .dropdown-menu, .dropdown-menu__trigger, .dropdown-menu__content,
          .dropdown-menu__item, .dropdown-menu__label,
          .dropdown-menu__separator, .dropdown-menu__shortcut
   ------------------------------------------------------------------------- */

/* ---- Root ---- */

.dropdown-menu {
  position: relative;
  display: inline-flex;
}

/* ---- Trigger ---- */

.dropdown-menu__trigger {
  display: inline-flex;
  align-items: center;
  cursor: pointer;
}

/* ---- Content (floating panel) ---- */

.dropdown-menu__content {
  position: absolute;
  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;
  transform-origin: top;
  animation: dropdown-menu-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
}

.dropdown-menu__content--bottom {
  top: calc(100% + 4px);
  --dropdown-menu-origin: top;
}

.dropdown-menu__content--top {
  bottom: calc(100% + 4px);
  --dropdown-menu-origin: bottom;
}

.dropdown-menu__content--align-start { left: 0; }
.dropdown-menu__content--align-end { right: 0; }

@keyframes dropdown-menu-in {
  from {
    opacity: 0;
    transform: scale(0.96) translateY(-2px);
  }
  to {
    opacity: 1;
    transform: scale(1) translateY(0);
  }
}

/* ---- Label ---- */

.dropdown-menu__label {
  padding: 8px 8px 4px;
  font-size: 12.8px;
  font-weight: 600;
  color: #0a0a0a;
}

/* ---- Item ---- */

.dropdown-menu__item {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 8px;
  border: none;
  border-radius: 4px;
  background: none;
  color: #0a0a0a;
  font-family: inherit;
  font-size: 12.8px;
  line-height: 1;
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background-color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}

.dropdown-menu__item:hover {
  background-color: #f5f5f5;
  color: #171717;
}

.dropdown-menu__item:focus-visible {
  outline: 2px solid var(--focus-ring-color);
  outline-offset: -2px;
  border-radius: 4px;
}

.dropdown-menu__item--disabled {
  color: #525252;
  cursor: not-allowed;
  pointer-events: none;
}

.dropdown-menu__item--destructive {
  color: #f84131;
}

.dropdown-menu__item--destructive:hover {
  background-color: #f84131;
  color: #0a0a0a;
}

/* ---- Item icon ---- */

.dropdown-menu__item-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 1em;
  height: 1em;
}

.dropdown-menu__item-icon svg {
  width: 100%;
  height: 100%;
}

/* ---- Shortcut ---- */

.dropdown-menu__shortcut {
  margin-left: auto;
  font-size: 10.24px;
  color: #525252;
  letter-spacing: 0.04em;
}

/* ---- Separator ---- */

.dropdown-menu__separator {
  height: 1px;
  margin: 4px calc(-1 * 4px);
  background-color: #e5e5e5;
}

/* ---- Reduced motion ---- */

@media (prefers-reduced-motion: reduce) {
  .dropdown-menu__item {
    transition-duration: 0ms;
  }

  .dropdown-menu__content {
    animation-duration: 0ms;
  }
}

Codigo fuente

components-react
components/atoms/DropdownMenu.tsx
import {
  type ReactNode,
  type KeyboardEvent,
  useState,
  useRef,
  useEffect,
  useCallback,
  createContext,
  useContext,
} from 'react';

function cn(...classes: (string | false | undefined | null)[]) {
  return classes.filter(Boolean).join(' ');
}

/* ---- Context ---- */

type DropdownMenuContextValue = {
  open: boolean;
  setOpen: (v: boolean) => void;
};

const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);

function useDropdownMenu() {
  const ctx = useContext(DropdownMenuContext);
  if (!ctx) throw new Error('DropdownMenu components must be used within <DropdownMenu>');
  return ctx;
}

/* ---- Root ---- */

export type DropdownMenuProps = {
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  children: ReactNode;
  className?: string;
};

export function DropdownMenu({
  open: controlledOpen,
  onOpenChange,
  children,
  className,
}: DropdownMenuProps) {
  const [internalOpen, setInternalOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement>(null);

  const open = controlledOpen ?? internalOpen;

  const setOpen = useCallback(
    (v: boolean) => {
      onOpenChange ? onOpenChange(v) : setInternalOpen(v);
    },
    [onOpenChange],
  );

  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 (
    <DropdownMenuContext.Provider value={{ open, setOpen }}>
      <div ref={rootRef} className={cn('dropdown-menu', className)}>
        {children}
      </div>
    </DropdownMenuContext.Provider>
  );
}

/* ---- Trigger ---- */

export function DropdownMenuTrigger({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  const { open, setOpen } = useDropdownMenu();

  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      setOpen(true);
    }
  };

  return (
    <div
      role="button"
      tabIndex={0}
      aria-expanded={open}
      aria-haspopup="menu"
      className={cn('dropdown-menu__trigger', className)}
      onClick={() => setOpen(!open)}
      onKeyDown={handleKeyDown}
    >
      {children}
    </div>
  );
}

/* ---- Content ---- */

export type DropdownMenuContentProps = {
  side?: 'top' | 'bottom';
  align?: 'start' | 'end';
  children: ReactNode;
  className?: string;
};

export function DropdownMenuContent({
  side = 'bottom',
  align = 'start',
  children,
  className,
}: DropdownMenuContentProps) {
  const { open } = useDropdownMenu();
  if (!open) return null;

  return (
    <div
      role="menu"
      className={cn(
        'dropdown-menu__content',
        `dropdown-menu__content--${side}`,
        `dropdown-menu__content--align-${align}`,
        className,
      )}
    >
      {children}
    </div>
  );
}

/* ---- Label ---- */

export function DropdownMenuLabel({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  return <div className={cn('dropdown-menu__label', className)}>{children}</div>;
}

/* ---- Group ---- */

export function DropdownMenuGroup({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  return <div role="group" className={className}>{children}</div>;
}

/* ---- Item ---- */

export type DropdownMenuItemProps = {
  variant?: 'default' | 'destructive';
  disabled?: boolean;
  onSelect?: () => void;
  children: ReactNode;
  className?: string;
};

export function DropdownMenuItem({
  variant = 'default',
  disabled = false,
  onSelect,
  children,
  className,
}: DropdownMenuItemProps) {
  const { setOpen } = useDropdownMenu();

  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>
  );
}

/* ---- Shortcut ---- */

export function DropdownMenuShortcut({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  return <span className={cn('dropdown-menu__shortcut', className)}>{children}</span>;
}

/* ---- Separator ---- */

export function DropdownMenuSeparator({ className }: { className?: string }) {
  return <div role="separator" className={cn('dropdown-menu__separator', className)} />;
}

Webflow

Webflowdropdown-menu

Pega en el Designer como application/json, luego convierte a Component (Atom / DropdownMenu) 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)

  • :focus-visible on .dropdown-menu__item:focus-visiblepseudo-class not a safe Designer variant — moved to head Custom Code
  • selector on .dropdown-menu__item-icon svgcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .dropdown-menu__item:focus-visiblecompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • @media on (prefers-reduced-motion: reduce)not a Designer breakpoint — moved to head Custom Code block
  • --focus-ring-color on :roottoken not found in tokens-nested.json — resolve upstream or the declaration stays invalid on paste
  • --focus-ring-width on :roottoken not found in tokens-nested.json — resolve upstream or the declaration stays invalid on paste

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

Componentes relacionados

On this page

Detalles

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

Componente

DropdownMenu

Source

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