AlertDialog
A blocking prompt with a clear decision — continue or cancel, including destructive. Irreversible actions.
Variants: defaultdestructive (default: primary)
Sizes: defaultsm (default: default)
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Destructive confirm:
```tsx import { AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '@/components/atoms/AlertDialog';
<AlertDialog> <AlertDialogTrigger><button type="button">Delete</button></AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle>Delete project?</AlertDialogTitle> <AlertDialogDescription>This cannot be undone.</AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> <AlertDialogCancel /> <AlertDialogAction variant="destructive-primary" onAction={() => {}} /> </AlertDialogFooter> </AlertDialogContent> </AlertDialog> ```
## Accesibilidad
- `role="alertdialog"` + body scroll lock; Escape cancels. Overlay does **not** click-dismiss — require Cancel/Action. - Use `destructive-primary` on Action only for irreversible ops.
## Cuándo no usar
- Multi-field forms or rich content → `Dialog`. - Non-blocking tips → toast / inline text, not a blocking alert.
## Criterio de uso
- Reserva AlertDialog para decisiones destructivas o irreversibles: borrar, revocar, cancelar algo en curso. - El titulo debe nombrar la consecuencia, no la accion: "Se eliminaran 3 archivos" informa mas que "Confirmar". - No lo uses para exito o informacion: interrumpir sin una decision real entrena a cerrar sin leer.
## Gotchas
- A diferencia de Dialog, cerrar por fuera NO debe interpretarse como confirmacion: la accion destructiva solo ocurre en su boton. - La accion peligrosa va en `destructive-primary` y nunca como boton por defecto del foco inicial.
Uso
import {
AlertDialog, AlertDialogTrigger, AlertDialogContent,
AlertDialogHeader, AlertDialogTitle, AlertDialogDescription,
AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
} from '@/components/atoms/AlertDialog';
<AlertDialog>
<AlertDialogTrigger><button type="button">Delete</button></AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project?</AlertDialogTitle>
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel />
<AlertDialogAction variant="destructive-primary" onAction={() => {}} />
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| open | boolean | — | `true` / `false` | Controlled visibility of the alert dialog. | Omit for uncontrolled Trigger flow. Pass open + onOpenChange for destructive confirms driven by app state. |
Gotchas
- a11y
Content uses role=alertdialog and locks body scroll; Escape cancels. Overlay does not click-dismiss (unlike Dialog) — require Cancel/Action.
- react
Compose AlertDialogTrigger + AlertDialogContent with Header/Title/Description/Footer + Cancel/Action. Action variant destructive-primary for irreversible ops.
Anatomía CSS
<div class="alert-dialog"> <span class="alert-dialog__description"></span> <span class="alert-dialog__footer"></span> <span class="alert-dialog__header"></span> <span class="alert-dialog__media"></span> <span class="alert-dialog__media--destructive"></span> <span class="alert-dialog__title"></span> </div>
| Clase | Propósito |
|---|---|
alert-dialog | root |
alert-dialog--exiting | modifier |
alert-dialog--sm | modifier |
alert-dialog__description | element |
alert-dialog__footer | element |
alert-dialog__header | element |
alert-dialog__media | element |
alert-dialog__media--destructive | element |
alert-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 | #f84131 | destructive |
| all | fg | #525252 | muted.foreground |
| all | border | 1px | stroke.hairline |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
@keyframes alert-dialog-in | | |
@keyframes alert-dialog-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).
/* -------------------------------------------------------------------------
Alert Dialog
Non-dismissible confirmation modal. No overlay close, no X button.
Requires explicit Cancel or Action to close.
Sizes: default (512px), sm (400px)
Parts: .alert-dialog, .alert-dialog__header, .alert-dialog__media,
.alert-dialog__title, .alert-dialog__description,
.alert-dialog__footer
------------------------------------------------------------------------- */
/* ---- Content ---- */
.alert-dialog {
position: fixed;
z-index: 50;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: calc(100% - 32px);
max-width: 512px;
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: alert-dialog-in 300ms cubic-bezier(0.22, 1, 0.36, 1);
}
.alert-dialog--sm {
max-width: 400px;
}
.alert-dialog--exiting {
animation: alert-dialog-out 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes alert-dialog-in {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes alert-dialog-out {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
to {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
}
/* ---- Header ---- */
.alert-dialog__header {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 24px;
padding-bottom: 0;
text-align: center;
}
/* ---- Media ---- */
.alert-dialog__media {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 9999px;
background-color: #f5f5f5;
color: #525252;
padding: 12px;
}
.alert-dialog__media--destructive {
background-color: #f84131;
color: #0a0a0a;
}
.alert-dialog__media svg {
width: 100%;
height: 100%;
}
/* ---- Title ---- */
.alert-dialog__title {
font-size: 20px;
font-weight: 600;
line-height: 1.45;
color: #0a0a0a;
margin: 0;
}
/* ---- Description ---- */
.alert-dialog__description {
font-size: 12.8px;
line-height: 1.45;
color: #525252;
margin: 0;
}
/* ---- Footer ---- */
.alert-dialog__footer {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.alert-dialog,
.alert-dialog--exiting {
animation-duration: 0ms;
}
}
Codigo fuente
import { type ReactNode, useState, useEffect, useCallback, useRef, createContext, useContext, } from 'react'; import { Button } from '../atoms/Button'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } /* ---- Context ---- */ type AlertDialogContextValue = { open: boolean; setOpen: (v: boolean) => void; }; // Debe coincidir con la animacion de salida de alert-dialog--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 AlertDialogContext = createContext<AlertDialogContextValue | null>(null); function useAlertDialog() { const ctx = useContext(AlertDialogContext); if (!ctx) throw new Error('AlertDialog components must be used within <AlertDialog>'); return ctx; } /* ---- Root ---- */ export type AlertDialogProps = { open?: boolean; onOpenChange?: (open: boolean) => void; children: ReactNode; }; export function AlertDialog({ open: controlledOpen, onOpenChange, children }: AlertDialogProps) { const [internalOpen, setInternalOpen] = useState(false); const open = controlledOpen ?? internalOpen; const setOpen = useCallback( (v: boolean) => { onOpenChange ? onOpenChange(v) : setInternalOpen(v); }, [onOpenChange], ); return ( <AlertDialogContext.Provider value={{ open, setOpen }}> {children} </AlertDialogContext.Provider> ); } /* ---- Trigger ---- */ export function AlertDialogTrigger({ children, className }: { children: ReactNode; className?: string }) { const { setOpen } = useAlertDialog(); 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 AlertDialogContentProps = { size?: 'default' | 'sm'; children: ReactNode; className?: string; }; export function AlertDialogContent({ size = 'default', children, className }: AlertDialogContentProps) { const { open, setOpen } = useAlertDialog(); 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]); // Escape triggers cancel (closes) useEffect(() => { if (!mounted) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [mounted, setOpen]); // 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. 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. En un alertdialog importa aun mas: es una decision // que el usuario tiene que resolver antes de seguir. 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')} aria-hidden="true" /> <div ref={contentRef} role="alertdialog" aria-modal="true" tabIndex={-1} className={cn('alert-dialog', size === 'sm' && 'alert-dialog--sm', exiting && 'alert-dialog--exiting', className)} > {children} </div> </> ); } /* ---- Header ---- */ export function AlertDialogHeader({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('alert-dialog__header', className)}>{children}</div>; } /* ---- Media ---- */ export type AlertDialogMediaProps = { variant?: 'default' | 'destructive'; children: ReactNode; className?: string; }; export function AlertDialogMedia({ variant = 'default', children, className }: AlertDialogMediaProps) { return ( <div className={cn('alert-dialog__media', variant === 'destructive' && 'alert-dialog__media--destructive', className)}> {children} </div> ); } /* ---- Title ---- */ export function AlertDialogTitle({ children, className }: { children: ReactNode; className?: string }) { return <h2 className={cn('alert-dialog__title', className)}>{children}</h2>; } /* ---- Description ---- */ export function AlertDialogDescription({ children, className }: { children: ReactNode; className?: string }) { return <p className={cn('alert-dialog__description', className)}>{children}</p>; } /* ---- Footer ---- */ export function AlertDialogFooter({ children, className }: { children: ReactNode; className?: string }) { return <div className={cn('alert-dialog__footer', className)}>{children}</div>; } /* ---- Cancel ---- */ export type AlertDialogCancelProps = { children?: ReactNode; className?: string; }; export function AlertDialogCancel({ children = 'Cancel', className }: AlertDialogCancelProps) { const { setOpen } = useAlertDialog(); return ( <Button variant="secondary" size="m" className={className} onClick={() => setOpen(false)}> {children} </Button> ); } /* ---- Action ---- */ export type AlertDialogActionProps = { variant?: 'primary' | 'destructive-primary'; onAction?: () => void; children?: ReactNode; className?: string; }; export function AlertDialogAction({ variant = 'primary', onAction, children = 'Continue', className, }: AlertDialogActionProps) { const { setOpen } = useAlertDialog(); return ( <Button variant={variant} size="m" className={className} onClick={() => { onAction?.(); setOpen(false); }} > {children} </Button> ); }
Webflow
Pega en el Designer como application/json, luego convierte a Component (Atom / AlertDialog) 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)
selectoron.alert-dialog__media svg— 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
Tras pegar: Create component → nombre Atom / AlertDialog → Publish.