Dialog
Dims the page and centers a modal you must dismiss. Confirmations and short focused tasks.
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Focused edit task:
```tsx import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogBody, DialogFooter, } from '@/components/atoms/Dialog';
<Dialog> <DialogTrigger><button type="button">Edit</button></DialogTrigger> <DialogContent> <DialogHeader> <DialogTitle>Edit profile</DialogTitle> <DialogDescription>Update your public name.</DialogDescription> </DialogHeader> <DialogBody>{/* fields */}</DialogBody> <DialogFooter><button type="button">Save</button></DialogFooter> </DialogContent> </Dialog> ```
## Accesibilidad
- `role="dialog"` `aria-modal`; Escape + overlay click close; body scroll locks. Always include `DialogTitle`. - `showCloseButton` lives on `DialogContent` (default true).
## Cuándo no usar
- Binary destructive confirms → `AlertDialog`. - Edge panels on mobile → `Drawer` / `Sheet`.
## Criterio de uso
- Usa Dialog para una tarea corta que necesita el contexto de la pagina detras: editar un campo, confirmar detalles, ver un resumen. - El estado vive fuera (`open` / `onOpenChange`): el componente no decide cuando abrirse, y eso permite abrir desde una ruta, un atajo o una respuesta del servidor. - Da siempre una salida visible ademas de Escape; un overlay sin boton de cerrar deja fuera a quien navega con puntero.
## Gotchas
- Al ser controlado, olvidar `onOpenChange` deja el dialogo imposible de cerrar: Escape y el overlay llaman a ese callback, no a un estado interno. - El foco entra al abrir y debe volver al disparador al cerrar; si abres desde un menu que ya se desmonto, guarda la referencia antes.
Uso
import {
Dialog, DialogTrigger, DialogContent, DialogHeader,
DialogTitle, DialogDescription, DialogBody, DialogFooter,
} from '@/components/atoms/Dialog';
<Dialog>
<DialogTrigger><button type="button">Edit</button></DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>Update your public name.</DialogDescription>
</DialogHeader>
<DialogBody>{/* fields */}</DialogBody>
<DialogFooter><button type="button">Save</button></DialogFooter>
</DialogContent>
</Dialog>Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| open | boolean | — | `true` / `false` | Controlled visibility of the modal dialog. | Omit for uncontrolled Trigger flow. Pass open + onOpenChange for forms opened from tables/rows. |
Gotchas
- a11y
role=dialog aria-modal with Escape + overlay click to close; body scroll locks while mounted. Always include DialogTitle for accessible name.
- react
showCloseButton lives on DialogContent (default true). Prefer Dialog for multi-field tasks; use AlertDialog for binary confirms.
Anatomía CSS
<div class="dialog"> <span class="dialog__body"></span> <span class="dialog__close"></span> <span class="dialog__content"></span> <span class="dialog__content--exiting"></span> <span class="dialog__description"></span> <span class="dialog__footer"></span> <span class="dialog__footer--sticky"></span> <span class="dialog__header"></span> <span class="dialog__overlay"></span> <span class="dialog__overlay--exiting"></span> <span class="dialog__title"></span> </div>
| Clase | Propósito |
|---|---|
dialog__body | element |
dialog__close | element |
dialog__content | element |
dialog__content--exiting | element |
dialog__description | element |
dialog__footer | element |
dialog__footer--sticky | element |
dialog__header | element |
dialog__overlay | element |
dialog__overlay--exiting | element |
dialog__title | 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 | #525252 | muted.foreground |
| all | border | 1px | stroke.hairline |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
@keyframes dialog-overlay-in | | |
@keyframes dialog-overlay-out | | |
@keyframes dialog-content-in | | |
@keyframes dialog-content-out | | |
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).
/* -------------------------------------------------------------------------
Dialog
Modal window with overlay. Focus-trapped, scroll-locked.
Composable: Dialog > Trigger + Content > Header + Footer
Parts: .dialog__overlay, .dialog__content, .dialog__header,
.dialog__title, .dialog__description, .dialog__footer,
.dialog__close
------------------------------------------------------------------------- */
/* ---- Overlay ---- */
.dialog__overlay {
position: fixed;
inset: 0;
z-index: 40;
background-color: #00000080;
animation: dialog-overlay-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialog__overlay--exiting {
animation: dialog-overlay-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes dialog-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dialog-overlay-out {
from { opacity: 1; }
to { opacity: 0; }
}
/* ---- Content ---- */
.dialog__content {
position: fixed;
z-index: 50;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: calc(100% - 32px);
max-width: 512px;
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
border: 1px solid #e5e5e5;
border-radius: 16px;
background-color: #ffffff;
color: #0a0a0a;
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
animation: dialog-content-in 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.dialog__content--exiting {
animation: dialog-content-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes dialog-content-in {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes dialog-content-out {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
to {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
}
/* ---- Header ---- */
.dialog__header {
display: flex;
flex-direction: column;
gap: 8px;
padding: 24px;
padding-bottom: 0;
}
/* ---- Title ---- */
.dialog__title {
font-size: 20px;
font-weight: 600;
line-height: 1.45;
color: #0a0a0a;
margin: 0;
}
/* ---- Description ---- */
.dialog__description {
font-size: 12.8px;
line-height: 1.45;
color: #525252;
margin: 0;
}
/* ---- Body (scrollable) ---- */
.dialog__body {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* ---- Footer ---- */
.dialog__footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 24px;
padding-top: 0;
}
.dialog__footer--sticky {
border-top: 1px solid #e5e5e5;
padding-top: 24px;
}
/* ---- Close button ---- */
.dialog__close {
position: absolute;
top: 16px;
right: 16px;
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.dialog__overlay,
.dialog__overlay--exiting,
.dialog__content,
.dialog__content--exiting {
animation-duration: 0ms;
}
}
Codigo fuente
import { type ReactNode, useState, useEffect, useCallback, useRef, createContext, useContext, } from 'react'; import { IconButton } from '../atoms/IconButton'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } // Debe coincidir con la animacion de salida de dialog__content--exiting en el CSS. const EXIT_DURATION_MS = 200; const FOCUSABLE = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(','); const CloseIcon = () => ( <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <line x1="18" y1="6" x2="6" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" /> </svg> ); /* ---- Context ---- */ type DialogContextValue = { open: boolean; setOpen: (v: boolean) => void; }; const DialogContext = createContext<DialogContextValue | null>(null); function useDialog() { const ctx = useContext(DialogContext); if (!ctx) throw new Error('Dialog components must be used within <Dialog>'); return ctx; } /* ---- Root ---- */ export type DialogProps = { open?: boolean; onOpenChange?: (open: boolean) => void; children: ReactNode; }; export function Dialog({ open: controlledOpen, onOpenChange, children }: DialogProps) { const [internalOpen, setInternalOpen] = useState(false); const open = controlledOpen ?? internalOpen; const setOpen = useCallback( (v: boolean) => { onOpenChange ? onOpenChange(v) : setInternalOpen(v); }, [onOpenChange], ); return ( <DialogContext.Provider value={{ open, setOpen }}> {children} </DialogContext.Provider> ); } /* ---- Trigger ---- */ export function DialogTrigger({ children, className }: { children: ReactNode; className?: string }) { const { setOpen } = useDialog(); return ( <div role="button" tabIndex={0} className={className} style={{ display: 'inline-flex' }} onClick={() => setOpen(true)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(true); } }} > {children} </div> ); } /* ---- Content ---- */ export type DialogContentProps = { showCloseButton?: boolean; children: ReactNode; className?: string; }; export function DialogContent({ showCloseButton = true, children, className }: DialogContentProps) { const { open, setOpen } = useDialog(); const [exiting, setExiting] = useState(false); const [mounted, setMounted] = useState(false); const contentRef = useRef<HTMLDivElement>(null); // `open` es lo unico que decide si el dialogo esta abierto. Si el consumidor lo // controla y veta el cierre, el contenido tiene que quedarse: desmontar desde // aqui lo dejaba cerrado para siempre, porque `open` ya nunca vuelve a cambiar. // `mounted` solo estira la vida en el DOM lo que dura la animacion de salida. useEffect(() => { if (open) { setMounted(true); setExiting(false); return; } if (!mounted) return; setExiting(true); const timer = setTimeout(() => { setMounted(false); setExiting(false); }, EXIT_DURATION_MS); return () => clearTimeout(timer); }, [open, mounted]); const handleClose = useCallback(() => { setOpen(false); }, [setOpen]); // Escape key useEffect(() => { if (!mounted) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [mounted, handleClose]); // Scroll lock useEffect(() => { if (!mounted) return; const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; }, [mounted]); // Al abrir, el foco entra al dialogo; al cerrar vuelve a quien lo abrio, que es // donde estaba el usuario antes de la interrupcion. Sin esta vuelta, el foco cae // al principio del documento y quien navega con teclado pierde el sitio. useEffect(() => { if (!mounted) return; const opener = document.activeElement as HTMLElement | null; contentRef.current?.focus(); return () => { if (opener && document.contains(opener)) opener.focus(); }; }, [mounted]); // Focus trap real: con aria-modal el lector de pantalla ya ignora el fondo, pero // el Tab del navegador no, y sin esto el foco se escapa a la pagina de atras. useEffect(() => { if (!mounted) return; const node = contentRef.current; if (!node) return; const handler = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; const focusables = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE)); if (focusables.length === 0) { e.preventDefault(); node.focus(); return; } const first = focusables[0]; const last = focusables[focusables.length - 1]; const active = document.activeElement; const outside = !node.contains(active); if (e.shiftKey && (active === first || active === node || outside)) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && (active === last || outside)) { e.preventDefault(); first.focus(); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [mounted]); if (!mounted) return null; return ( <> <div className={cn('dialog__overlay', exiting && 'dialog__overlay--exiting')} onClick={handleClose} aria-hidden="true" /> <div ref={contentRef} role="dialog" aria-modal="true" tabIndex={-1} className={cn('dialog__content', exiting && 'dialog__content--exiting', className)} > {children} {showCloseButton && ( <IconButton variant="tertiary" size="xs" className="dialog__close" icon={<CloseIcon />} aria-label="Close" onClick={handleClose} /> )} </div> </> ); } /* ---- Header ---- */ export function DialogHeader({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('dialog__header', className)}>{children}</div>; } /* ---- Title ---- */ export function DialogTitle({ children, className }: { children: ReactNode; className?: string }) { return <h2 className={cn('dialog__title', className)}>{children}</h2>; } /* ---- Description ---- */ export function DialogDescription({ children, className }: { children: ReactNode; className?: string }) { return <p className={cn('dialog__description', className)}>{children}</p>; } /* ---- Body ---- */ export function DialogBody({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('dialog__body', className)}>{children}</div>; } /* ---- Footer ---- */ export type DialogFooterProps = { sticky?: boolean; children: ReactNode; className?: string; }; export function DialogFooter({ sticky = false, children, className }: DialogFooterProps) { return ( <div className={cn('dialog__footer', sticky && 'dialog__footer--sticky', className)}> {children} </div> ); }
Webflow
Pega en el Designer como application/json, luego convierte a Component (Atom / Dialog) 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 / Dialog → Publish.