Dialog
stimeo--dialog
An accessible modal dialog. Keeps focus inside, stops background scrolling, closes on Esc.
Implements the WAI-ARIA modal dialog pattern. Opening moves focus into the dialog and keeps it there as you Tab. Background scrolling stops while it is open. Esc closes it and focus returns to the button that opened it. Clicking the backdrop closes it too.
Confirm action
Are you sure you want to continue?
Keyboard
| Key | Action |
|---|---|
| Tab / Shift+Tab | Cycle focus within the dialog (focus trap). |
| Esc | Close the dialog and return focus to the trigger. |
<%# Markup for the dialog demo.
The single source used for both the live render and the copy-paste code.
stimeo--dialog provides the focus trap, Esc, and background scroll lock.
The behavior is the library's; the look (classes) is the Playground's CSS. %>
<div class="dialog" data-controller="stimeo--dialog">
<button
type="button"
class="demo-trigger"
data-stimeo--dialog-target="trigger"
data-action="click->stimeo--dialog#open">
<%= t("components.dialog.demo.open_button") %>
</button>
<%# The role="dialog" + aria-modal region. Starts hidden (closed).
Clicking this element itself (the backdrop) also closes it. %>
<div
class="dialog__backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
data-stimeo--dialog-target="dialog"
data-action="click->stimeo--dialog#closeOnBackdrop"
hidden>
<div class="dialog__panel">
<h3 id="dialog-title" class="dialog__title"><%= t(
"components.dialog.demo.confirm_title"
) %></h3>
<p><%= t("components.dialog.demo.confirm_desc") %></p>
<div class="dialog__actions">
<button
type="button"
class="demo-trigger"
data-action="click->stimeo--dialog#close"><%= t(
"components.dialog.demo.cancel"
) %></button>
<button
type="button"
class="demo-trigger demo-trigger--primary"
data-action="click->stimeo--dialog#close"><%= t(
"components.dialog.demo.ok"
) %></button>
</div>
</div>
</div>
</div>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.events") %></span>
<span class="demo-readout__value" data-readout>—</span>
</p>
/* Use the role="dialog" region as a centered backdrop. */
.dialog__backdrop {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.5);
z-index: 50;
}
/* Setting display explicitly overrides the hidden attribute's default display:none, so
re-declare display:none while closed to honor the library's hidden control. */
.dialog__backdrop[hidden] {
display: none;
}
.dialog__panel {
width: min(28rem, 100%);
padding: 1.5rem;
background: var(--bg);
border-radius: 0.5rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
}
.dialog__title {
margin: 0 0 0.5rem;
}
.dialog__actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
// Every move of the open state is reported, with a `reason` saying what drove it
// — a trigger, Escape, a click outside, or a call from your own code. A real app
// subscribes to mirror the state elsewhere or to send an analytics event; this
// demo writes the event name and its reason into the readout so both are visible
// while you interact. `reason: "api"` is the one to watch for: a close you asked
// for comes back to your own listener.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const stateReadout = demoStage.querySelector("[data-readout]");
const stateRoot = demoStage.querySelector("[data-controller~='stimeo--dialog']");
for (const name of ["open", "close"]) {
stateRoot?.addEventListener(`stimeo--dialog:${name}`, (event) => {
const reason = event.detail?.reason;
if (stateReadout) {
stateReadout.textContent = reason
? `stimeo--dialog:${name} · reason: ${reason}`
: `stimeo--dialog:${name}`;
}
});
}
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--dialog"
Targets
| Name | Description | Attribute |
|---|---|---|
trigger
required
|
The button that opens the dialog; focus returns here on close. | data-stimeo--dialog-target="trigger" |
dialog
required
|
The dialog element (role=dialog). Clicking it outside its content acts as the backdrop and closes. |
data-stimeo--dialog-target="dialog" |
Actions
| Name | Description | Action |
|---|---|---|
close
|
Closes the dialog and returns focus to the trigger. | stimeo--dialog#close |
closeOnBackdrop
|
Closes when the backdrop (the dialog element outside its content) is clicked. Wire it on the dialog element. | stimeo--dialog#closeOnBackdrop |
open
|
Opens the dialog, moves focus inside, traps Tab, and locks background scroll. | stimeo--dialog#open |
Events
| Name | Description | Event |
|---|---|---|
close
|
Fires right after the dialog closes; detail.reason says what moved it. |
stimeo--dialog:close |
open
|
Fires right after the dialog opens; detail.reason says what moved it. |
stimeo--dialog:open |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
hidden |
Dialog element | Present when closed, removed when open — toggles visibility. |
aria-modal="true" |
Dialog element | Marks the dialog as modal for assistive technology. |
inert |
Background siblings | Applied to sibling elements while open so they cannot be interacted with. |