ATOM
Components (registry)Surfaces

AlertDialog

A blocking prompt with a clear decision — continue or cancel, including destructive. Irreversible actions.

Preview

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

PropTipoDefaultRango / opcionesWhatHow
openboolean`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>
ClasePropósito
alert-dialogroot
alert-dialog--exitingmodifier
alert-dialog--smmodifier
alert-dialog__descriptionelement
alert-dialog__footerelement
alert-dialog__headerelement
alert-dialog__mediaelement
alert-dialog__media--destructiveelement
alert-dialog__titleelement

Tokens resueltos

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

VariantePropValorToken
allbg#f84131destructive
allfg#525252muted.foreground
allborder1pxstroke.hairline

Animaciones

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

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

Webflowalert-dialog

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)

  • selector on .alert-dialog__media svgcompound/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

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

Componentes relacionados

On this page

Detalles

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

Componente

AlertDialog

Source

components-react / cssDisponible via MCP: atom_uikit_source("alert-dialog")
Abrir en Storybook