DropdownMenu
Click a trigger and a menu drops with actions or nested groups. App chrome and overflow menus.
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
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| open | boolean | — | `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>
| Clase | Propósito |
|---|---|
dropdown-menu | root |
dropdown-menu__content | element |
dropdown-menu__content--align-end | element |
dropdown-menu__content--align-start | element |
dropdown-menu__content--bottom | element |
dropdown-menu__content--top | element |
dropdown-menu__item | element |
dropdown-menu__item--destructive | element |
dropdown-menu__item--disabled | element |
dropdown-menu__item-icon | element |
dropdown-menu__label | element |
dropdown-menu__separator | element |
dropdown-menu__shortcut | element |
dropdown-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 | #e5e5e5 | border |
| all | fg | #525252 | muted.foreground |
| all | border | none | (unparsed) |
| all | hover-bg | #f84131 | destructive |
| all | hover-fg | #0a0a0a | destructive.foreground |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
background-color | var(--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
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
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-visibleon.dropdown-menu__item:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Codeselectoron.dropdown-menu__item-icon svg— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.dropdown-menu__item:focus-visible— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)@mediaon(prefers-reduced-motion: reduce)— not a Designer breakpoint — moved to head Custom Code block--focus-ring-coloron:root— token not found in tokens-nested.json — resolve upstream or the declaration stays invalid on paste--focus-ring-widthon:root— token not found in tokens-nested.json — resolve upstream or the declaration stays invalid on paste
Tras pegar: Create component → nombre Atom / DropdownMenu → Publish.