ATOM
Components (registry)Surfaces

Dialog

Dims the page and centers a modal you must dismiss. Confirmations and short focused tasks.

Preview

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

PropTipoDefaultRango / opcionesWhatHow
openboolean`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>
ClasePropósito
dialog__bodyelement
dialog__closeelement
dialog__contentelement
dialog__content--exitingelement
dialog__descriptionelement
dialog__footerelement
dialog__footer--stickyelement
dialog__headerelement
dialog__overlayelement
dialog__overlay--exitingelement
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#ffffffpopover
allfg#525252muted.foreground
allborder1pxstroke.hairline

Animaciones

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

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

Webflowdialog

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)

  • @media on (prefers-reduced-motion: reduce)not a Designer breakpoint — moved to head Custom Code block

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

Componentes relacionados

On this page

Detalles

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

Componente

Dialog

Source

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