Skip to content

Modal

The surface that interrupts. It is a real <dialog> opened with showModal(), and that one call is the whole mechanism: the top layer, the scrim, the focus trap, inert on everything behind it, Escape and the focus handed back to whatever opened it. There is no portal, no scroll-lock effect and no document-level key listener — React only keeps its idea of open and the DOM's from drifting apart, and the stylesheet paints the three rows.

  • Stable
  • React 19
  • Native <dialog> + showModal()
  • No portal

¿Descartar los cambios?

Se perderá todo lo que escribiste desde la última vez.

Puedes seguir editando o salir sin guardar nada.

Press it. That is the real thing: showModal(), the top layer, the scrim, the focus trap and Escape. Every example on this page starts from a button, because a dialog you cannot open is not this component.

¿Descartar los cambios?

Se perderá todo lo que escribiste desde la última vez.

Puedes seguir editando o salir sin guardar nada.

última respuesta: —

Code
import { useState } from "react";
import { Button, Modal } from "wayra-ui";
export function DiscardDemo() {
const [open, setOpen] = useState(false);
const [answer, setAnswer] = useState("—");
function discard() {
setAnswer("descartado");
setOpen(false);
}
function keepEditing() {
setAnswer("sigue editando");
setOpen(false);
}
return (
// The dialog is written where it is used — no portal, nothing mounted at
// the root of the app. `showModal()` is what lifts it to the top layer, so
// it opens over this whole page rather than inside the frame.
<div className="hu-demo-col">
<Button onClick={() => setOpen(true)}>Descartar</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="¿Descartar los cambios?"
description="Se perderá todo lo que escribiste desde la última vez."
closeLabel="Cerrar"
footer={
<>
<Button variant="outline" color="neutral" onClick={keepEditing}>
Seguir editando
</Button>
<Button color="error" onClick={discard}>
Descartar
</Button>
</>
}
>
Puedes seguir editando o salir sin guardar nada.
</Modal>
<p className="hu-readout">última respuesta: {answer}</p>
</div>
);
}

A trigger you own, and a controlled dialog. That is the whole consumer side: there is no imperative handle, no <Modal.Trigger> and nothing to mount at the root of the app. title, description and footer are slots the grid places; children is the body.

Open the Code panel under any example on this page and you get that example’s whole source — the trigger, the state and the dialog — because every one of them is a mounted component and not a picture of one.

showModal() is not a starting point the component builds on — it is the implementation. Everything in this table is the platform’s, and the library re-implements none of it:

What Where it comes from
Top layer, above every z-index on the page showModal()
The scrim the element’s own ::backdrop, which only a modal dialog gets
Focus moved into the dialog on open showModal()
Focus trap — the page behind it cannot be tabbed, clicked or focused inert applied by showModal()
The page stops scrolling two space toggles on :root — see below
Escape the cancel event, then the UA’s own close() — intercepted a step earlier when dismissible={false}, see the refusal
Focus handed back to the trigger on close showModal()

What the component adds is short: it reconciles React’s open with element.open (calling showModal() twice throws InvalidStateError, and Escape can already have closed it), reports every transition the DOM actually made through onOpen and onClose, and turns a refused dismissal into the shake.

The dialog stays exactly where you wrote it in the React tree. The browser lifts it to the top layer on its own, which is what makes a portal unnecessary — and skipping the portal is what keeps a [data-theme] on an ancestor resolving, since tokens are inherited custom properties and document.body is outside that subtree.

There is no wrapper box. The element you get back is the <dialog>, and it is also the grid: three rows, auto minmax(0, 1fr) auto.

<dialog class="wayra-modal" data-size="lg" aria-labelledby="t">
<header class="wayra-modal-header">
<span class="wayra-modal-icon" aria-hidden="true">…</span>
<h2 class="wayra-modal-title" id="t">…</h2>
<p class="wayra-modal-desc">…</p>
<button type="button" class="wayra-modal-close" aria-label="Cerrar">…</button>
</header>
<div class="wayra-modal-body">…</div>
<footer class="wayra-modal-footer">…</footer>
</dialog>

The body is always rendered, even when children is empty: it is the row that scrolls, and the grid places it by class rather than by whichever siblings happened to show up. The header appears only when one of icon, title, description or closeLabel has something in it; the footer only when footer does.

Five buttons, five steps, one mounted dialog: size is the only thing they change before it opens.

md — 23rem

El escalón por defecto, el ancho clásico de un aviso, escrito como data-size="md".

size="md"

Code
import { useState } from "react";
import type { ModalSize } from "wayra-ui";
import { Button, Modal } from "wayra-ui";
// `ModalSize` is exported for exactly this: state that drives the prop is typed
// by the component rather than by a union copied out of it.
type Step = ModalSize;
// Module scope, not inline: a fresh object on every render is the one mistake
// the component's performance rules exist to prevent.
const STEPS: readonly Step[] = ["xs", "sm", "md", "lg", "xl"];
const COPY: Record<Step, { title: string; body: string }> = {
xs: {
title: "xs — 15rem",
body: "La confirmación compacta: una pregunta y dos botones, nada más.",
},
sm: {
title: "sm — 18,5rem",
body: "El escalón corto: una frase y dos botones.",
},
md: {
title: "md — 23rem",
body: 'El escalón por defecto, el ancho clásico de un aviso, escrito como data-size="md".',
},
lg: {
title: "lg — 30rem",
body: "El escalón largo, para una lista o un formulario de una columna.",
},
xl: {
title: "xl — 40,5rem",
body: "El escalón ancho, para tablas y formularios de dos columnas.",
},
};
export function SizesDemo() {
// `step` outlives the close on purpose: resetting it with `open` would resize
// the dialog while it is still playing its exit.
const [step, setStep] = useState<Step>("md");
const [open, setOpen] = useState(false);
function show(next: Step) {
setStep(next);
setOpen(true);
}
const copy = COPY[step];
return (
<div className="hu-demo-col">
<div className="hu-demo-row">
{STEPS.map((option) => (
<Button
key={option}
variant="outline"
color="neutral"
onClick={() => show(option)}
>
Modal {option}
</Button>
))}
</div>
<Modal
open={open}
onClose={() => setOpen(false)}
size={step}
title={copy.title}
closeLabel="Cerrar"
footer={<Button onClick={() => setOpen(false)}>Entendido</Button>}
>
{copy.body}
</Modal>
<p className="hu-readout">size="{step}"</p>
</div>
);
}

Open sm and xl back to back — the reading column is the only thing that moves.

<Modal size="xs">…</Modal>
<Modal size="sm">…</Modal>
<Modal>…</Modal> {/* md — the default, written as data-size="md" */}
<Modal size="lg">…</Modal>
<Modal size="xl">…</Modal>
Step Width knob At the default 0.22rem unit
xs 68 × --wayra-size-field 14.96rem
sm 84 × --wayra-size-field 18.48rem
md 104 × --wayra-size-field 22.88rem
lg 136 × --wayra-size-field 29.92rem
xl 184 × --wayra-size-field 40.48rem

This is the modality ladder, not the field ladder every control walks: a dialog is a reading column, not a control, so md sits at the classic alert width rather than at a control’s middle step. The unit is per theme, so those rem figures move with it — brutal sets 0.25rem and the whole ladder grows.

xs is the compact confirm and is Modal’s alone: Drawer starts at sm, because a panel docked to an edge at that width is a strip rather than a surface.

md is the default, and the component writes the step it resolved — every defaulted attribute in the library is written. An alert defaults to sm instead, so there the default writes data-size="sm". Hand-written markup may leave the attribute off: the stylesheet reads a missing data-size as md, or as sm on an alert. The used width is min(var(--wayra-modal-width), calc(100% - 2rem)), so every step keeps a gutter on a narrow viewport instead of running off it.

No se pudo guardar

Revisa la conexión y vuelve a intentarlo.

reintentos: 0

Code
import { useState } from "react";
import { Button, Modal } from "wayra-ui";
// The library ships no icons: `icon` takes whatever node you pass, and this one
// is the consumer's. Module scope, so it is not rebuilt on every render.
const WARNING_GLYPH = (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
style={{ inlineSize: "1.35em", blockSize: "1.35em" }}
>
<path d="M12 3.5 1.8 20.5h20.4L12 3.5Z" />
<path d="M12 10v4M12 17.2h.01" />
</svg>
);
export function AlertDemo() {
const [open, setOpen] = useState(false);
const [retries, setRetries] = useState(0);
function retry() {
setRetries((count) => count + 1);
setOpen(false);
}
// No `size`: the alert variant already defaults to the `sm` step, so a
// question is never wider than it needs to be.
return (
<div className="hu-demo-col">
<Button color="warning" onClick={() => setOpen(true)}>
Guardar
</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
variant="alert"
color="warning"
icon={WARNING_GLYPH}
title="No se pudo guardar"
description="Revisa la conexión y vuelve a intentarlo."
footer={
<>
<Button
variant="outline"
color="neutral"
onClick={() => setOpen(false)}
>
Cancelar
</Button>
<Button color="warning" onClick={retry}>
Reintentar
</Button>
</>
}
/>
<p className="hu-readout">reintentos: {retries}</p>
</div>
);
}

The dialog that demo mounts, with the state and the glyph left out:

<Modal
variant="alert"
color="warning"
icon={<WarningGlyph />}
title="No se pudo guardar"
description="Revisa la conexión y vuelve a intentarlo."
footer={
<>
<Button variant="outline" color="neutral">Cancelar</Button>
<Button color="warning">Reintentar</Button>
</>
}
/>

variant="alert" is the APG Alert Dialog: a question that has to be answered before anything else. It changes four things at once — role="alertdialog" on the element, a centred header, a footer whose actions share the row evenly, and a narrower default column (the sm step, so a question is never wider than it needs to be). An explicit size still wins, md included, and whichever step the alert ends on is the one written.

The role is derived from this prop, which is why ModalProps drops the native role attribute: a raw role would not survive.

Sesión caducada

Vuelve a entrar para seguir trabajando.

Escape and the scrim both close this one — the refusal below is what turns them off.

Open it and look at the surface behind the circle: still base-100, in whichever theme the bar above is set to.

Code
import { useState } from "react";
import { Button, Modal } from "wayra-ui";
// The library ships no icons: `icon` takes whatever node you pass, and this one
// is the consumer's. Module scope, so it is not rebuilt on every render.
const WARNING_GLYPH = (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
style={{ inlineSize: "1.35em", blockSize: "1.35em" }}
>
<path d="M12 3.5 1.8 20.5h20.4L12 3.5Z" />
<path d="M12 10v4M12 17.2h.01" />
</svg>
);
export function ColorDemo() {
const [open, setOpen] = useState(false);
// `color="error"` tints the icon circle and nothing else: the surface behind
// it is the same base-100 every other dialog paints. The glyph is
// `aria-hidden`, so what it means is in the title as well — colour is never
// the only carrier.
return (
<div className="hu-demo-col">
<Button color="error" onClick={() => setOpen(true)}>
Sesión caducada
</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
variant="alert"
color="error"
icon={WARNING_GLYPH}
title="Sesión caducada"
description="Vuelve a entrar para seguir trabajando."
footer={
<Button color="error" onClick={() => setOpen(false)}>
Volver a entrar
</Button>
}
/>
<p className="hu-hint">
Escape and the scrim both close this one — the refusal below is what
turns them off.
</p>
</div>
);
}

color travels as data-color and defaults to primary. It tints the icon circle only — the accent at full strength on a 15% wash of itself — and the surface itself stays neutral, because a dialog is read, not signalled. There is no data-color on the root’s background, no coloured border and no tinted footer.

The glyph is aria-hidden, so whatever it means has to be in the title or the description as well: colour is never the only carrier. The library ships no icons — icon takes whatever node you pass.

By default Escape and a click on the backdrop both close the dialog. dismissible={false} refuses both, and the surface shakes instead — the wrong-password gesture, the same one PinCode plays.

<Modal dismissible={false} title="Sesión caducada" footer={<Button>Volver a entrar</Button>}>
No se guardará nada hasta que entres otra vez.
</Modal>

The shake only exists while the dialog is open and being dismissed, so this one is live: open it and then try Escape, or click the scrim — as many times as you like.

That last part is why Escape is refused on its own key event rather than on the cancel it would otherwise become. A close request may only be cancelled while the window carries history-action user activation, and cancelling one spends it; Escape never grants it back. So a refusal that lived in cancel alone would hold the first press and let the second one through. Nothing about this reaches the API — it is the same dismissible={false}, and a surface inside the dialog that answers Escape itself (a Popover, a nested dialog, an open Dropdown menu or ComboBox list) still takes the press first. The dialog does not shake over it: the surface closing is the answer.

Sesión caducada

Vuelve a entrar para seguir trabajando.

No se guardará nada hasta que entres otra vez.

Open it, then press Escape or click the scrim: both are refused and the surface shakes. The footer is the only way out.

Code
import { useState } from "react";
import { Button, Modal } from "wayra-ui";
// Module scope: a node rebuilt on every render is the one mistake the
// component's performance rules exist to prevent. The library ships no icons,
// so this is the consumer's own glyph.
const WARNING_GLYPH = (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
aria-hidden="true"
style={{ inlineSize: "1.35em", blockSize: "1.35em" }}
>
<path d="M12 3.5 1.8 20.5h20.4L12 3.5Z" />
<path d="M12 10v4M12 17.2h.01" />
</svg>
);
export function RefusalDemo() {
const [open, setOpen] = useState(false);
// No `closeLabel`: a dialog that refuses Escape and the scrim needs its way
// out in the footer, and a close button there as well would only be a second
// one. Nothing announces the refusal — the dialog is still open and still
// holds the focus, which is the state.
return (
<div className="hu-demo-col">
<Button color="error" onClick={() => setOpen(true)}>
Sesión caducada
</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
variant="alert"
color="error"
icon={WARNING_GLYPH}
dismissible={false}
title="Sesión caducada"
description="Vuelve a entrar para seguir trabajando."
footer={
<Button color="error" onClick={() => setOpen(false)}>
Volver a entrar
</Button>
}
>
No se guardará nada hasta que entres otra vez.
</Modal>
<p className="hu-hint">
Open it, then press Escape or click the scrim: both are refused and the
surface shakes. The footer is the only way out.
</p>
</div>
);
}

There is no attribute for dismissible: refusing to close is behaviour, and what it produces on screen is data-deny, written straight onto the element by the hook and removed again on animationend. A DOM write rather than state — re-rendering the surface would disturb whatever the user is doing inside it — and because the attribute is removed before it is re-added, the shake replays on every refusal instead of firing once.

Under prefers-reduced-motion the keyframes are swapped, not removed: one rise and one fall of an error-coloured outline. Losing the animation would lose the feedback and strand the attribute, since animationend is what clears it.

<Modal mobileSheet title="Compartir">…</Modal>

Below 30rem a centred card is the wrong shape, so mobileSheet turns it into a sheet attached to the bottom edge: full width, bottom corners squared off, and an entrance that rises from the edge the thumb is on instead of scaling up from the middle.

Compartir

Elige dónde enviarlo.

Copiar el enlace, enviarlo por correo o exportarlo como PDF.

Above 30rem this opens as the centred card it always was. Narrow the window past that breakpoint — or open the page on a phone — and the same dialog is a sheet on the bottom edge instead.

Code
import { useState } from "react";
import { Button, Modal } from "wayra-ui";
export function SheetDemo() {
const [open, setOpen] = useState(false);
// `mobileSheet` writes `data-mobile="sheet"` and the breakpoint is a plain
// CSS media query: nothing is measured and nothing re-renders, so the same
// open dialog changes shape when the window crosses 30rem.
return (
<div className="hu-demo-col">
<Button onClick={() => setOpen(true)}>Compartir</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
mobileSheet
title="Compartir"
description="Elige dónde enviarlo."
closeLabel="Cerrar"
footer={<Button onClick={() => setOpen(false)}>Listo</Button>}
>
Copiar el enlace, enviarlo por correo o exportarlo como PDF.
</Modal>
</div>
);
}

It travels as data-mobile="sheet" and the breakpoint is a plain CSS media query — nothing is measured, nothing re-renders, and above the breakpoint the attribute costs one unmatched selector. A viewport query is physical by definition; there is no logical form of it.

As a sheet it can also be pulled down to close, by its header or any spot that is not a control or the body. It is the bottom Drawer’s gesture with the same rules — the 10px start, the rubber band upwards, the close past half the height or on a flick, the exit that slides off at the hand’s speed, dismissible={false} refusing with the shake — documented on the Drawer page. The breakpoint is still written once: the media query sets a private flag that the hook reads at the press, so above 30rem the centred card takes no pull at all. It draws no handle of its own.

The dialog is the grid, so the header and the footer stay put and only the body scrolls. That middle row is a scroll container with min-block-size: 0 and overscroll-behavior: contain, which is also what carries the component through 200% text: long content lengthens the scroll instead of pushing the actions out of the box.

Términos del servicio

Desplázate hasta el final para aceptarlos.

1. El servicio se presta tal como está. No garantizamos que esté disponible sin interrupciones ni que sea apto para un fin concreto.

2. La cuenta es personal e intransferible. Eres responsable de todo lo que ocurra bajo tus credenciales, incluidas las de los equipos que invites.

3. Los contenidos que subas siguen siendo tuyos. Nos concedes únicamente la licencia necesaria para almacenarlos, mostrarlos y respaldarlos.

4. Puedes exportar tus datos en cualquier momento y en un formato abierto. La exportación no borra nada por sí sola.

5. Conservamos los registros de acceso durante noventa días y después los agregamos sin identificadores personales.

6. El plan se renueva automáticamente al final de cada periodo. Puedes cancelarlo antes de la renovación sin dar explicaciones.

7. Los precios se muestran con impuestos incluidos para las cuentas particulares y sin ellos para las empresariales.

8. Un uso que degrade el servicio para el resto puede limitarse, previo aviso salvo que la urgencia lo impida.

9. Avisaremos de cualquier cambio material en estos términos con treinta días de antelación por correo.

10. La ley aplicable es la del país donde se emite la factura, y los tribunales competentes son los de esa jurisdicción.

términos: pendientes

Ten clauses and a checkbox. The title and the two actions stay put; only the middle row moves.

Code
import { useState } from "react";
import { Button, Checkbox, Modal } from "wayra-ui";
// Module scope, not inline: a fresh array on every render is the one mistake
// the component's performance rules exist to prevent.
const CLAUSES = [
"1. El servicio se presta tal como está. No garantizamos que esté disponible sin interrupciones ni que sea apto para un fin concreto.",
"2. La cuenta es personal e intransferible. Eres responsable de todo lo que ocurra bajo tus credenciales, incluidas las de los equipos que invites.",
"3. Los contenidos que subas siguen siendo tuyos. Nos concedes únicamente la licencia necesaria para almacenarlos, mostrarlos y respaldarlos.",
"4. Puedes exportar tus datos en cualquier momento y en un formato abierto. La exportación no borra nada por sí sola.",
"5. Conservamos los registros de acceso durante noventa días y después los agregamos sin identificadores personales.",
"6. El plan se renueva automáticamente al final de cada periodo. Puedes cancelarlo antes de la renovación sin dar explicaciones.",
"7. Los precios se muestran con impuestos incluidos para las cuentas particulares y sin ellos para las empresariales.",
"8. Un uso que degrade el servicio para el resto puede limitarse, previo aviso salvo que la urgencia lo impida.",
"9. Avisaremos de cualquier cambio material en estos términos con treinta días de antelación por correo.",
"10. La ley aplicable es la del país donde se emite la factura, y los tribunales competentes son los de esa jurisdicción.",
];
export function ScrollDemo() {
const [open, setOpen] = useState(false);
const [accepted, setAccepted] = useState(false);
function close() {
setOpen(false);
}
// The checkbox is the tab stop axe's `scrollable-region-focusable` asks for:
// a region the mouse can scroll and the keyboard cannot reach is a failure.
// Gating the footer on it is what makes reaching it the task rather than a
// formality — and the header and footer stay put while it scrolls, because
// the dialog is the grid and only the body is a scroll container.
return (
<div className="hu-demo-col">
<Button onClick={() => setOpen(true)}>Leer los términos</Button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="Términos del servicio"
description="Desplázate hasta el final para aceptarlos."
closeLabel="Cerrar"
footer={
<>
<Button variant="outline" color="neutral" onClick={close}>
Cancelar
</Button>
<Button disabled={!accepted} onClick={close}>
Aceptar
</Button>
</>
}
>
{CLAUSES.map((clause) => (
<p key={clause}>{clause}</p>
))}
<Checkbox
checked={accepted}
onChange={(event) => setAccepted(event.currentTarget.checked)}
label="He leído y acepto los términos"
/>
</Modal>
<p className="hu-readout">
términos: {accepted ? "aceptados" : "pendientes"}
</p>
</div>
);
}

The same scroll container clips whatever is drawn in flow past its edge. A Tooltip never enters the top layer, so a tip inside the body is cut at the body’s border: give it room on the side it opens toward.

title renders as an <h2> and is wired as the dialog’s accessible name through aria-labelledby; description renders as a <p> under it and joins aria-describedby.

  • A consumer aria-labelledby wins — pass one and the generated title id is not used.
  • A consumer aria-describedby is merged — the description’s id is appended to it rather than replacing it, the way a field merges its hint id.
  • Ids are built off id when you pass one, and off useId when you do not.

closeLabel is both the accessible name of the close button and the switch that renders it: no label, no button. It is required text rather than a default so a dialog is translated by construction — ComboBox does the same with its four labels — and an empty string renders nothing at all, since a button with no accessible name is worse than no button.

const [open, setOpen] = useState(false);
<Modal open={open} onClose={() => setOpen(false)}>…</Modal>

open makes React the owner; defaultOpen seeds the uncontrolled state once. Either way pair it with onClose: Escape, a backdrop click and the close button all close the dialog without asking React first, and that callback is the only thing keeping the two in step — a controlled dialog that ignored it would reopen on the next render.

The two callbacks report the state the dialog reached, not the one React asked for, so each fires exactly once per transition whoever caused it. They are read from the platform: onClose from the dialog’s own close event — which is why the props drop the native onClose handler, this one being the same event minus the close an unmount raises — and onOpen from the showModal() call, because a dialog never opens by itself and so has no event for it. onOpen therefore also fires on the first render under defaultOpen.

The close button is part of that: it closes the element, not the state, so a controlled dialog closes even before the consumer has moved open, and the close event stays the single place the transition is reported from.

The native open attribute is deliberately not a prop. A dialog opened through markup is the non-modal one — no top layer, no scrim, no trap — which is precisely the state this component exists to avoid. It is also why no example on this page is a <dialog open> drawn in the page: one would paint the surface correctly and document the wrong thing.

The class plus the data-* attributes is the public API, and the stylesheet is written against it. Below there is no React at all — a plain <dialog> and the showModal() any page can call:

<button type="button" class="wayra-button" onclick="dlg.showModal()">
Descartar cambios
</button>
<dialog id="dlg" class="wayra-modal" aria-labelledby="dlg-t">
<header class="wayra-modal-header">
<h2 class="wayra-modal-title" id="dlg-t">¿Descartar los cambios?</h2>
<p class="wayra-modal-desc">Se perderá todo lo que escribiste desde la última vez.</p>
<button type="button" class="wayra-modal-close" aria-label="Cerrar"
onclick="dlg.close()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12"/><path d="M18 6L6 18"/>
</svg>
</button>
</header>
<div class="wayra-modal-body">Puedes seguir editando o salir sin guardar nada.</div>
<footer class="wayra-modal-footer">
<button type="button" class="wayra-button">Aceptar</button>
</footer>
</dialog>

That markup paints the same surface, opens into the same top layer and gets the same trap, scrim and Escape — because all of it was the platform’s to begin with. What hand-written markup does not get is the reconciliation between React’s open and the DOM’s, and the refusal to close.

showModal() makes the page inert — it cannot be clicked, tabbed into or focused — but it does not stop it scrolling. That part is the library’s, and it is CSS with no JavaScript behind it at all:

:root {
--wayra-modality-open: initial;
}
:root:has(:is(.wayra-modal, .wayra-drawer):modal) {
--wayra-modality-open: ;
overflow: var(--wayra-modality-open) hidden;
}

--wayra-modality-open is a space toggle: set to a single space it is a valid empty value, so var() substitutes to nothing and overflow: hidden stands; set to initial it is the guaranteed-invalid value, the substitution fails, and the property computes to unset. A boolean in plain CSS.

The lock itself sits behind the :has(), so while nothing is open the library writes nothing on your root but the toggle: an overflow or a scrollbar-gutter of yours on html, in any layer, stays in force.

:modal matches only a dialog opened with showModal(), so one opened through the open attribute alone never locks anything.

Locking takes the root scrollbar away, and the page jumps sideways by its width. The obvious fix is to reserve it back:

:root { scrollbar-gutter: stable; }

That is right on a page that had a scrollbar and wrong on one that did not: scrollbar-gutter: stable reserves its width without checking, so on a short page it narrows the content and everything slides. So the reservation carries a second toggle, and a scroll timeline answers the question CSS otherwise cannot:

:root:has(:is(.wayra-modal, .wayra-drawer):modal) {
--_scrolls: initial;
animation: var(--wayra-modality-open) wayra-modality-has-scroll forwards;
animation-timeline: var(--wayra-modality-open) scroll();
scrollbar-gutter: var(--_scrolls) var(--wayra-modality-open) stable;
}
@keyframes wayra-modality-has-scroll { 0%, 100% { --_scrolls: ; } }

A scroll timeline is inactive while its container has nothing to scroll, and an inactive timeline never applies its animation — so the keyframe raises the toggle on a page that really scrolls and on no other. Both offsets carry the same value: what is read is whether the animation applies, never how far the scroll has got.

Scroll-driven animations are Chrome 115+ and Safari 26+, above this library’s floor, and Firefox does not have them yet. There the toggle never turns on, scrollbar-gutter computes to auto, and no gutter is reserved: the lock still works and the page jumps by a scrollbar, which is the safe direction. A page that wants the reservation everywhere declares scrollbar-gutter: stable on its own root, permanently and outside a layer — while a surface is open the library’s rule outranks a layered one.

Locking the root has one collateral effect, and the stylesheet undoes it for you:

:root:has(:is(.wayra-modal, .wayra-drawer):modal) body {
overflow: visible;
}

A root at overflow: visible does not own the viewport’s overflow — it propagates up from <body>, and the body’s own used value becomes visible in exchange. The viewport is the scrollport, and position: sticky in your page anchors to it.

Locking ends that arrangement. The root is no longer visible, so the propagation stops and a body that carried anything else becomes a scroll container of its own, at scrollTop: 0. Every sticky element re-anchors to it and jumps up by the page’s scroll offset — which looks exactly like the header disappearing. body { overflow-x: hidden } is enough to trigger it, it is in Tailwind’s base layer, and it forces the other axis to compute to auto.

Holding the body at visible while a surface is open keeps the viewport the only scrollport, so nothing re-anchors.

<html> is not the scroll container in every app shell — a fixed header over a <main> that scrolls is common, and there overflow: hidden on the root does nothing. The toggle is public precisely so you can point the lock at whatever really scrolls:

main { overflow: var(--wayra-modality-open) hidden; }

The same toggle drives chrome of your own that has to react while a surface is open — a fixed toolbar, a floating action bar. You never write the :has() list yourself.

backdrop blurs what is behind the scrim. It is off by default — the design language is flat and opaque, and this prop frosts the scrim, never the surface, which stays base-100 at full opacity.

backdrop backdrop-filter
none (default) none; written as data-backdrop="none", which no rule matches
xs blur(2px)
sm blur(4px)
md blur(8px)
lg blur(16px)
xl blur(24px)
2xl blur(40px)
3xl blur(64px)

Each named step is one literal rule in modality.css matched on data-backdrop, so it works on hand-written markup and at the browser floor.

backdrop takes those eight values and nothing else. A free CSS length used to be accepted as a ninth, riding a public knob written on the element through style; it was withdrawn on 2026-08-30 along with that knob.

It was the one rule in the file that read a custom property, and so the only one carrying an engine condition — a ::backdrop inherits from the element that opened it only in newer engines. Every step that remains is a literal, so the whole prop now works on hand-written markup and at the browser floor, with no condition and nothing to validate.

Under prefers-reduced-transparency: reduce and under forced-colors: active the blur is removed whatever the prop says — a frosted scrim is the effect the first setting exists to turn off, and the second exists to make edges legible.

Blur is the expensive filter: it repaints the whole page behind the scrim on every frame of the entrance. That is the other reason it is opt-in.

The surface is opaque base-100 and carries no shadow: a modal has no border of its own, so the scrim is the whole of its separation from the page. It was raised by a lit top edge and a layered drop shadow until 2026-08-29, and translucency was withdrawn earlier still. The scrim is one dark wash in both schemes, because a light wash over a dark page reads as a flash instead of as depth.

backdrop-filter came back on 2026-08-30, by request and in one place only: the backdrop prop, which frosts the scrim and is off unless asked for. The surface itself still carries none.

The rest of the ::backdrop block reads no custom property and every value in it is a literal: below Chrome 122 a ::backdrop does not inherit from the element that originates it.

Property Default Scope
--wayra-modality-padding 1.35rem family — Modal and Drawer
--wayra-modality-radius calc(var(--wayra-radius-box) * 1.8) family
--wayra-modality-close-size max(24px, 1.75rem) family
--wayra-modal-width the size step, 22.88rem at md Modal
--wayra-modal-max-height min(42rem, calc(100dvh - 3rem)) Modal

The --wayra-modality-* knobs are family API the way --_wayra-field-* is: one padding, one corner and one close-button size for both surfaces, declared once. The corner is softer than a card’s, the way an alert is softer than the window it sits on, and the close size carries a 24px floor so WCAG 2.5.8 grows the hit area rather than the glyph. Everything else — --_wayra-modality-gutter, the entrance transform, the deny animation name — is private.

The legacy pair is gone. showModal() inerts everything behind it by itself, and a non-modal surface that only looks like a dialog is Popover.

No imperative handle, no root-level provider

Section titled “No imperative handle, no root-level provider”

Nothing to mount, nothing to ref into. The dialog is where you wrote it and open is how it opens.

The HTML invoker API (<button commandfor command="show-modal">) and the closedby attribute would replace the reconciliation plumbing wholesale once they clear the browser floor. Neither is used yet.

ModalProps extends ComponentProps<"dialog"> minus open, role and title, so id, style, ref, className, every aria-* and every data-* land on the dialog itself. The spread runs before the component’s own attributes, so a colliding data-size loses to the prop.

Prop Type Default Description
children ReactNode — The body: the one row that scrolls. Give it a tab stop if it overflows.
variant "dialog" | "alert" "dialog" alert is the APG Alert Dialog — role="alertdialog", centred content, equal actions. Travels as data-variant.
color WayraColor "primary" Semantic role, as data-color. Tints the icon circle only.
size ModalSize — the whole WayraSize scale "md", "sm" on an alert Width step off the modality ladder. Travels as data-size, the variant’s default included; an explicit size wins on an alert too. Exported under that name, so state driving it can be typed by the component.
backdrop ModalityBackdrop "none" Blurs the scrim. Travels as data-backdrop. See The frosted scrim.
dismissible boolean true Whether Escape, a backdrop click and, on the phone sheet, a pull down close it. false refuses all three and shakes instead.
mobileSheet boolean false Below 30rem, render as a sheet on the bottom edge that a pull down closes. Travels as data-mobile="sheet".
icon ReactNode — Decorative glyph in a circle above the title, in the role colour. aria-hidden.
title ReactNode — The <h2>, wired as the accessible name through aria-labelledby.
description ReactNode — The line under it. Its id joins a consumer aria-describedby.
footer ReactNode — The action row. The dialog owns no actions and never closes itself from one.
closeLabel string — Accessible name of the close button, and the switch that renders it. Empty renders nothing.
open boolean — Controlled. Pair it with onClose.
defaultOpen boolean false Uncontrolled first render.
onClose () => void — Fires once whenever the dialog has closed, whoever closed it. Takes the name of the native close handler, which the props drop.
onOpen () => void — Fires once whenever the dialog has opened, defaultOpen’s first render included.
unstyled boolean false Drops the root’s class and every part’s; keeps the roles, the data-* and the whole platform behaviour.
Key Behavior
Escape Closes it. The browser’s close, arriving as the cancel event — there is no document-level key listener, so a popup inside the dialog consumes its own Escape first. With dismissible={false} the key is refused and the surface shakes, unless the press closed an open menu or list inside it.
Tab / Shift+Tab Cycles inside the dialog. The trap is inert applied by showModal(); nothing is emulated, so it knows about content the library never sees.
Click outside Closes it, or refuses. Only a point outside the border box counts as the backdrop — a click on the element that lands inside its box is content, not the scrim — and only when the press began there too: text selected in the body and released over the scrim closes nothing.
Pull down The mobileSheet below 30rem only, any pointer. Closes it, refuses, or springs back, as on the bottom Drawer. A shortcut for the keys above, never the only way out.
  • On open, the dialog is announced as a dialog — the role the element already carries — named by its title and described by its description. variant="alert" makes that alertdialog, which asks a screen reader to treat it as an interruption.
  • Focus moves in, and back out, on its own. showModal() focuses the first focusable element inside (an autoFocus elsewhere wins instead), and closing hands focus back to whatever had it before — unmounting an open dialog too, since the component closes it before React removes the node. Neither is the component’s decision.
  • A refusal announces nothing, deliberately. The dialog is still open and still holds the focus — that is the state. There is no live region and no status message for it; adding one would repeat what the user already sees and interrupt the field they are in.
  • The close button clears the 24px target floor and declares its own --_wayra-ring-color, so the one focus-ring rule in states.css draws it.
  • Reduced motion turns the entrance and the exit into a 160ms fade in place, with no travel and no scale, and swaps the shake for a still outline flash. The ::backdrop gets its fade as a property rather than a variable — the documented exception, since no custom property reaches a ::backdrop at the browser floor.
  • The exit mirrors the entrance. Closing runs the settle curve played backwards, for the surface and its scrim alike, so the dialog leaves along the path it came in by. A sheet pulled down is the one exception: it carries on down from where it was let go, or, under reduced motion, fades there, while the pull itself still follows the hand.
  • Windows high contrast draws a CanvasText border, because the surface carries no edge of its own and would sit on the page with nothing separating the two.

Do

¿Descartar los cambios?

Se perderá lo que escribiste.

A decision that blocks everything else, with a way out in the header and two answers in the footer.

Don't

Se guardó el borrador

Todo listo.

A notice that interrupts nothing, in a surface that traps focus and stops the page. That is a Toast or an Alert.

Do

Sesión caducada

Vuelve a entrar para seguir.

dismissible={false} with an action that closes it. Escape and the scrim refuse; the footer is the way out.

Don't

No se guardará nada hasta que entres otra vez.

dismissible={false}, no closeLabel and no footer. Escape refuses, the scrim refuses, and nothing inside it closes — a trap.