Modal
BetaInterrumpe para pedir una decisión o una tarea corta, y bloquea el resto de la página hasta resolverla.
registry/ds/modal.tsximport { Button } from "@/registry/ds/button";
import { Modal, ModalBody, ModalClose, ModalFooter, ModalHeader } from "@/registry/ds/modal";
export default function ModalBasic() {
return (
<Modal trigger={<Button variant="stroke">Abrir modal</Button>}>
<ModalHeader title="Cambiar de comisión" subtitle="El cambio se aplica desde la próxima clase." />
<ModalBody>
Vas a pasar de la comisión de la mañana a la de la tarde. Tus notas y asistencias se conservan.
</ModalBody>
<ModalFooter>
<ModalClose>
<Button variant="stroke">Cancelar</Button>
</ModalClose>
<ModalClose>
<Button>Cambiar comisión</Button>
</ModalClose>
</ModalFooter>
</Modal>
);
}Cuándo usarlo
Un modal es una interrupción. Usalo solo cuando la página no puede seguir sin la respuesta.
| Usá Modal para… | Usá otra cosa para… |
|---|---|
| Confirmar algo irreversible. | Avisar que algo salió bien → Toast. |
| Una tarea corta y autocontenida (un formulario de 2–4 campos). | Formularios largos o con varios pasos → una página. |
| Pedir un dato imprescindible para continuar. | Información de apoyo → Tooltip o Popover. |
| Onboarding y momentos de celebración. | Filtros, detalles, edición lateral → Drawer. |
Se arma por partes
ModalHeader + ModalBody + ModalFooter. Igual que en Figma (.Modal header y .Modal footer), los ocho tipos del set —Dialog, Confirm,
Scrollable, Form, Code, Survey, Onboarding, Celebration— son recetas de
esas tres piezas.
Confirmación
Icono centrado, sin ✕ (hay un Cancelar visible) y footer full.
import { Button } from "@/registry/ds/button";
import { AlertTriangle } from "@/registry/ds/icons";
import { Modal, ModalClose, ModalFooter, ModalHeader } from "@/registry/ds/modal";
export default function ModalConfirm() {
return (
<Modal size="sm" trigger={<Button variant="tonal">Dar de baja la materia</Button>}>
<ModalHeader
icon={<AlertTriangle />}
align="center"
closable={false}
title="¿Dar de baja Cálculo I?"
subtitle="Perdés el cupo y no podés volver a inscribirte este semestre."
/>
<ModalFooter layout="full">
<ModalClose>
<Button variant="stroke">Cancelar</Button>
</ModalClose>
<ModalClose>
<Button>Dar de baja</Button>
</ModalClose>
</ModalFooter>
</Modal>
);
}Contenido largo
El cuerpo scrollea; header y footer quedan fijos. El divisor del footer marca que hay más contenido detrás.
import { Button } from "@/registry/ds/button";
import { Modal, ModalBody, ModalClose, ModalFooter, ModalHeader } from "@/registry/ds/modal";
export default function ModalScrollable() {
return (
<Modal size="lg" trigger={<Button variant="stroke">Ver términos</Button>}>
<ModalHeader title="Términos de la beca" subtitle="Última actualización: marzo 2026" />
<ModalBody className="max-h-64 space-y-3">
{Array.from({ length: 8 }, (_, i) => (
<p key={i}>
{i + 1}. La beca cubre el porcentaje indicado de la matrícula mientras mantengas un promedio igual o
superior a 7 y no adeudes materias del ciclo anterior.
</p>
))}
</ModalBody>
<ModalFooter divider>
<ModalClose>
<Button variant="stroke">Ahora no</Button>
</ModalClose>
<ModalClose>
<Button>Aceptar términos</Button>
</ModalClose>
</ModalFooter>
</Modal>
);
}Reglas
- Un modal a la vez. Nunca un modal que abre otro.
- Acción principal a la derecha, con verbo + objeto ("Cambiar comisión"), nunca "OK" ni "Sí". La secundaria es literalmente "Cancelar".
- En acciones destructivas, el foco inicial va a Cancelar y el título es la pregunta completa: "¿Dar de baja Cálculo I?".
- Siempre se puede salir: ✕, Esc, clic afuera o Cancelar. Solo bloqueá el cierre si se perderían datos.
- El título dice de qué se trata; no repitas "Atención" o "Confirmar".
- En mobile ocupa casi todo el ancho; si el contenido no entra cómodo, es una página.
Medidas
| Size | Ancho máx. | Para qué |
|---|---|---|
sm | 400 | Confirmaciones. |
md | 480 | El default: diálogos y formularios cortos. |
lg | 560 | Contenido largo, media, onboarding. |
| Parte | Medida |
|---|---|
| Superficie | radius-2xl 16 · sin padding propio · alto máx. 100% − 32 |
| Header | padding 24 (arriba y lados) · título Text lg / Semibold · subtítulo Text sm |
| Icono destacado | círculo 48 · glifo 24 |
| Cerrar | círculo 28 a 16 del borde |
| Body | padding 24 × 16 · Text sm |
| Footer | padding 24 (abajo y lados) · gap spacing-xl 12 |
| Media | 16:9 a sangre |
Tokens
| Parte | Propiedad | Token |
|---|---|---|
| Scrim | Negro a opacity-scrim (60%) | — |
| Superficie | Fondo | bg/elevated |
| Superficie | Sombra | Elevation/xl |
| Título | Color | text/default |
| Subtítulo y cuerpo | Color | text/muted |
| Icono destacado | Fondo | primary/subtle |
| Icono destacado | Glifo | primary/accent |
| Cerrar | Icono → hover | icon/subtle |
| Divisor del footer | Borde | border/default |
En Figma
| Propiedad en Figma | Valores | En código | Nota |
|---|---|---|---|
| Modal › Size | sm · md · lg | size | — |
| Modal › Type | Dialog · Confirm · Scrollable · Form · Code · Survey · Onboarding · Celebration | — | Recetas: se arman componiendo header, body y footer. |
| .Modal header › Type | Plain · Icon left · Icon center · Illustration · Media · Banner | icon · align · media | — |
| Title · Subtitle · Show close | Texto · on/off | title · subtitle · closable | — |
| .Modal footer › Layout | Right · Full · Split · Stacked · Carousel | layout | Carousel (puntos de paso) todavía no está en código. |
| Divider | on/off | divider | — |
Instalación
npx shadcn@latest add https://polen.cdc.cool/r/modal.jsonPlayground
Configurá el componente y copiá el snippet: se genera del mismo estado que renderiza el preview.
<Modal trigger={<Button variant="stroke">Abrir modal</Button>}>
<ModalHeader title="Cambiar de comisión" subtitle="El cambio se aplica desde la próxima clase." />
<ModalBody>…</ModalBody>
<ModalFooter>
<ModalClose>
<Button variant="stroke">Cancelar</Button>
</ModalClose>
<Button onClick={save}>Cambiar comisión</Button>
</ModalFooter>
</Modal>Uso
<Modal trigger={<Button>Abrir</Button>}>
<ModalHeader title="Cambiar de comisión" />
<ModalBody>…</ModalBody>
<ModalFooter>
<ModalClose><Button variant="stroke">Cancelar</Button></ModalClose>
<Button onClick={save}>Cambiar comisión</Button>
</ModalFooter>
</Modal>Para abrirlo desde código, controlalo: <Modal open={open} onOpenChange={setOpen}>.
Props
Sobre Base UI Dialog.
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
Modal · trigger | ReactElement | — | El botón que lo abre. Opcional si es controlado. |
Modal · size | "sm" | "md" | "lg" | "md" | Ancho máximo 400 / 480 / 560. |
Modal · open / onOpenChange | boolean · (open) => void | — | Modo controlado. |
ModalHeader · title | ReactNode | — | Requerido. Es el nombre accesible del diálogo. |
ModalHeader · subtitle | ReactNode | — | Se anuncia como descripción. |
ModalHeader · icon · align | ReactNode · "left" | "center" | — | Icono destacado y alineación. |
ModalHeader · media | ReactNode | — | Imagen a sangre, arriba. |
ModalHeader · closable | boolean | true | Muestra la ✕. |
ModalFooter · layout | "right" | "full" | "split" | "stacked" | "right" | Disposición de las acciones. |
ModalFooter · divider | boolean | false | Línea superior, para cuerpos que scrollean. |
ModalClose | — | — | Envuelve un Button para que cierre el modal. |
Teclado
| Tecla | Qué hace |
|---|---|
| Esc | Cierra el modal. |
| Tab | Recorre solo los elementos del modal: el foco no se escapa a la página. |
| ShiftTab | Lo mismo, hacia atrás. |
Qué viene resuelto
role="dialog"con el título como nombre y el subtítulo como descripción.- Foco atrapado adentro; al cerrar vuelve al botón que lo abrió.
- El resto de la página queda inerte y sin scroll.
- Clic en el scrim cierra. La ✕ tiene nombre accesible.
- Sin animación con
prefers-reduced-motion.
Qué tenés que cuidar vos
- El foco inicial cae en el primer elemento enfocable. En confirmaciones destructivas, que ese sea Cancelar (ponelo primero en el footer).
- No abras un modal sin que la persona lo pida (al cargar la página, por tiempo).
- Si ocultás la ✕, dejá siempre un Cancelar visible.