Dialog
Tier B. Follows the anatomy template and the conventions.
1. Purpose and native element
Section titled “1. Purpose and native element”A modal window that asks for an answer or a short task before the page continues. It follows the
APG modal dialog pattern: role="dialog",
aria-modal="true", a name from its title, focus moved in on open and returned to the opener on close,
Tab kept inside, and Escape to close.
A Dialog is not a native <dialog>. It is a Strata Panel: a <section> at elevation.modal
that is a top-level panel, so the GPU presentation can enroll it as a layer like any other panel. What
<dialog>.showModal() does natively, showStrataDialog (@underglass/ui/dom) does for it: the background is
made inert, focus is placed and restored, Escape asks to close. A Drawer is the same component
fixed to an edge.
2. Object
Section titled “2. Object”A raised slab at the highest level: the Panel’s plate, at elevation.modal, centred over a scrim.
| Part | Shape | Height at 50% | Moves |
|---|---|---|---|
| Slab | Rounded plate with a bevel band of bevel.width |
elevation.modal (24) |
Enters and leaves through depth, from elevation.modal towards its own height (section 12); optionally tilted |
| Shadow | Analytic cast shadow from the light (section 7) | — | With the slab |
| Scrim | A flat dim layer over the page, behind the slab | — | Fades in and out |
The scrim is not part of the slab and carries no relief. The slab’s hit geometry is its box.
3. Faces and materials
Section titled “3. Faces and materials”As Panel: colour.panel, colour.text, matte by default;
data-strata-material overrides it (a glass dialog becomes matte under prefers-reduced-transparency).
The scrim is black at 40%, in every theme.
4. Elevation per state
Section titled “4. Elevation per state”| State | Relief | Objects | CSS |
|---|---|---|---|
| Open | elevation.modal (24) |
Slab at modal height | Relief recipe at modal (data-strata-elevation="modal") |
| Entering, leaving | Moves through depth; inert meanwhile | Same | ug-panel-enter and ug-panel-leave; none under reduced motion |
| Focus-visible | When the panel itself holds focus (no tabbable content): accent edge, focus.width |
Emissive glow on the slab edge | outline: focus.width solid accent |
Hover, pressed, disabled, selected and invalid do not apply. The controls inside own their own states.
5. Relief primitives
Section titled “5. Relief primitives”One raised primitive: the <section> itself, from its border box and border-radius (radius.panel), at
elevation.modal. Same budgets as Panel: at most 256 primitives per panel,
this one included.
6. Tokens it reads
Section titled “6. Tokens it reads”As Panel, with elevation.modal in place of elevation.floating, and
space.* (gaps and the margin to the viewport), font.size.subtitle and font.weight.strong for the title.
| Prop | Meaning |
|---|---|
size, material, level |
The Strata props |
7. DOM structure and overlay needs
Section titled “7. DOM structure and overlay needs”<div class="ug-scrim" aria-hidden="true"></div><section class="ug-panel ug-dialog" role="dialog" aria-modal="true" aria-labelledby="discard-title" aria-describedby="discard-description" data-strata-face="raised" data-strata-elevation="modal" tabindex="-1"> <h2 class="ug-dialog__title" id="discard-title">Discard changes?</h2> <div class="ug-dialog__body"> <p class="ug-dialog__description" id="discard-description">Your edits will be lost.</p> </div> <div class="ug-dialog__actions"> <button type="button" class="ug-button" data-strata-face="raised" data-strata-close> <span class="ug-button__label">Keep editing</span> </button> </div></section>Plain HTML and Astro render both elements closed, with the hidden attribute:
<div class="ug-scrim" aria-hidden="true" hidden></div><section class="ug-panel ug-dialog" role="dialog" aria-modal="true" aria-labelledby="discard-title" aria-describedby="discard-description" data-strata-face="raised" data-strata-elevation="modal" tabindex="-1" hidden> <h2 class="ug-dialog__title" id="discard-title">Discard changes?</h2> <div class="ug-dialog__body"> <p class="ug-dialog__description" id="discard-description">Your edits will be lost.</p> </div> <div class="ug-dialog__actions"> <button type="button" class="ug-button" data-strata-face="raised" data-strata-close> <span class="ug-button__label">Keep editing</span> </button> </div></section>- Top-level, rendered into a container. The
<section>and the scrim just before it are direct children of one container, never inside a layer or another panel. In React and Vue thecontainerprop selects it (the page body by default) and the component renders nothing while closed. On a page with@underglass/ui/gpu, render it into the Underglass root (container): the root is a stage, the dialog fills it rather than the viewport, and while it is open the panel is its own GPU layer (relief atelevation.modal, entering through depth) over a scrim the GPU fades.showStrataDialoghands both to the stage as they show and takes them back before they hide, so GPU stays on throughout. Other floating parts (menus, popovers, tooltips) still portal to the overlay host. - The
<section>is the boundary element and the enrollable layer. React and Vue refs,classandstylereach it. The body isoverflow: auto, so a tall dialog scrolls its body and keeps its title and actions. description(React and Vue; a paragraph in plain HTML) is the first child of the body and is thearia-describedbytarget.actionsis optional.- Plain HTML:
data-strata-open="{id}"on any element opens the panel with that id, anddata-strata-closeinside an open panel closes it (attachStrata).data-strata-close-on-scrimon the panel (thecloseOnScrimprop in React, Vue and Astro) makes a scrim click close it. - Script:
showStrataDialog(panel, { onClose, initialFocus, returnFocus, closeOnScrim })returns{ close() }.onClose(reason)receives"escape","scrim"or"close"; without it the dialog closes itself. React and Vue call it fromopen. - Stacking: a later panel and scrim in the DOM sit above an earlier pair, which is how nested dialogs stack.
8. Keyboard and screen reader
Section titled “8. Keyboard and screen reader”| Key | Action |
|---|---|
| Tab, Shift+Tab | Moves between the controls inside; never reaches the page behind, which is inert |
| Escape | Asks the topmost dialog to close; focus returns to the opener |
- Role
dialog,aria-modal="true", named by the title;aria-describedbywhen there is a description. - On open, focus moves to
initialFocus, else an[autofocus]descendant, else the first tabbable descendant, else the panel (tabindex="-1"). On close it returns to the opener, or toreturnFocus. - The background is made inert (every sibling of the panel and of its ancestors, up to
<body>) with each priorinertvalue restored exactly. With nested dialogs only the topmost answers Escape, and closing it restores the previous layer. - A click on the scrim closes the dialog only with
closeOnScrim, as a native<dialog>does not. - The panel is inert while it enters or leaves, so nothing inside takes input mid-motion.
9. CSS presentation
Section titled “9. CSS presentation”Fixed, centred without a transform (so an enrolled boundary never carries one): inset: 0; margin: auto,
width: min(32rem, 100vw - 2 × space.5), max-height: 100dvh - 2 × space.5, a flex column with the
title, a scrolling body and the actions, right-aligned. Padding follows the Panel’s density. The scrim fades
with a CSS opacity transition, and has none under prefers-reduced-motion: reduce, where entering and leaving
are instant too. Under forced-colors: Canvas background and a CanvasText border, no shadow.
10. Tests
Section titled “10. Tests”- Plain DOM: opening moves focus inside; Tab and Shift+Tab never land on the background, which is inert;
Escape closes and focus returns to the opener; with nested dialogs Escape closes only the top one;
closeOnScrimon and off; instant under reduced motion. - Bindings: React, Vue and Astro render exactly the markup above (open for React and Vue,
hiddenfor Astro). - Accessibility: axe in light and dark; the accessibility snapshot of an open Dialog equals that of a native
<dialog>opened withshowModal()and the same content. - GPU (
tests/browser/ui-gpu.test.mjs): opened by a real click, the scrim and panel become layers with no GPU drop; the panel has the relief material and is centred on the stage; the scrim dims the panel beneath; a real click on a drawn button closes it; both layers are let go before they hide; Escape returns focus to the opener; and the accessibility snapshot is the same as in ordinary DOM.
11. Plate form
Section titled “11. Plate form”The Dialog as a plate that rises out of the chassis (Instrument plan,
design language section 12): form="plate" (prop) or data-strata-form="plate" (on the <section>). Without it,
or with flush, the Dialog is the panel above, in the markup, the look and the motion it had before. Everything in sections 1, 7 and 8 holds: the
same modal contract (focus in and back, the page behind inert, Escape, the scrim, closeOnScrim), the same top-level panel in the same container, and
the same accessibility snapshot. Its controls are the same Strata components at the same level, standing on the plate.
<div class="ug-scrim" aria-hidden="true"></div><section class="ug-panel ug-dialog" role="dialog" aria-modal="true" aria-labelledby="discard-title" aria-describedby="discard-description" data-strata-face="raised" data-strata-elevation="modal" data-strata-form="plate" tabindex="-1"> <h2 class="ug-dialog__title" id="discard-title">Discard changes?</h2> <div class="ug-dialog__body"> <p class="ug-dialog__description" id="discard-description">Your edits will be lost.</p> </div> <div class="ug-dialog__actions"> <button type="button" class="ug-button" data-strata-face="raised" data-strata-close> <span class="ug-button__label">Keep editing</span> </button> </div></section>- At Relief and Objects a plate dialog is the dialog in the
platematerial with a tight edge break (CSS: half the bevel, a brushed finish), entering through depth as before. - At Instrument the plate rises. The panel is its own layer over the scrim, and the card’s plate form on it: a rim inside its box (a mesh swept
along its rounded outline, 4 wide, 3 high, a chamfered outer edge, polished). The panel’s motion drives it:
riseis the motion’s eased progress (themotion.slowduration andmotion.easing.standardthe depth entry uses), atravelpose from−(height − 0.3)px (just proud of the face) to 0 on entering and back on leaving, so the plate comes up out of the chassis as the layer scales in fromelevation.modaland fades. Under reduced motion the duration is 1 ms and the rise snaps. The rise is one number in the scene: a uniform update. It is the sametcore applies as a pose each frame, with no recapture and no hit-geometry update of its own; opening a plate dialog costs the captures, uploads and hit-geometry updates a flush dialog costs. A motion captures only what changes in the DOM (#165): a 2.4 s open makes 9 to 11 captures over its 177 frames (the panel and the scrim enrolled,inert, focus, the first build of the parts), none in the middle of the motion, and the close 3 to 5; the hit geometry follows the moving layer once a frame (about 143 updates), as it must. Until this was fixed the helper rewrote the scrim’sdata-strata-modewith the value it already had on every frame, and every DOM mutation is a capture, so the open recaptured every layer each frame (407 captures at Instrument, 408 for the flush dialog). A change made to the dialog’s content mid-motion is captured at once and the pixels at rest are the new ones. Under reduced motion the open snaps with 3 captures. The parts of an Instrument layer show about 0.1 s after it is enrolled. - Containment over the whole rise. Every vertex of the rim lies at least 0.5 px inside the dialog’s box, at every rise and depth and at every
corner and edge middle of the reference stage (core’s own check,
strata-structural-forms.unit.test.mjs: the closest is 1.9 px at 50% depth), so a pixel of the plate is never outside the dialog; against a flush dialog moving in step, no pixel outside the plate’s box differs at any sampled frame. - During the motion the dialog is inert (the Dialog contract: the panel is inert while it moves), so nothing takes a hit until it has come to rest; at rest every native hit lands on the drawn control.
- A dialog narrower than 22 px a side stays a Relief slab; the dialog’s width is never that small.
Tests. ui-structural-forms.test.mjs (modal behaviour in plain DOM, the rise at DPR 1 and 2 at all three levels, a plate and a flush dialog side by side
at the Instrument level), ui-structural-forms-bindings.test.mjs, ui-structural-forms-astro.test.mjs.