Dialog
Accessible modal dialog built on the native HTML <dialog> element.
Use this when
You need a modal — confirm, info, form, lightbox — that traps focus while open, returns focus on close, dismisses on Esc, and hides the background from screen readers. Use this recipe if you can target browsers with native <dialog> support (Baseline since 2022 across Chrome/Firefox/Safari/Edge). If you need older browser support, reach for a focus-trap library + [role="dialog"] div.
Structure (raw HTML)
<dialog data-cia-recipe="dialog" aria-labelledby="my-dialog-title">
<header data-slot="header">
<h2 id="my-dialog-title">Dialog title</h2>
<button data-slot="close" aria-label="Close" formmethod="dialog">×</button>
</header>
<main data-slot="body">
Dialog body content goes here.
</main>
<footer data-slot="footer">
<button data-slot="cancel" formmethod="dialog">Cancel</button>
<button data-slot="confirm" autofocus>Confirm</button>
</footer>
</dialog>
Notes on the markup:
<dialog>element is the source of truth — its.showModal()method does focus trap + Esc handling +aria-modal+ backdrop, all nativelyaria-labelledbypoints to the title element so screen readers announce the dialog by name on openformmethod="dialog"on the cancel/close buttons closes the dialog without a JS handler when the dialog is inside a<form>autofocuson the primary action sets initial focus when the dialog opens
Styling (cia mixins)
// MyDialog.module.scss — component stylesheet, so import the zero-emit barrel.
@use 'css-is-awesome/api' as cia;
.my-dialog {
@include cia.modal;
[data-slot="header"] { @include cia.toolbar; }
[data-slot="body"] { @include cia.stack($gap: 4); }
[data-slot="footer"] { @include cia.toolbar; }
[data-slot="close"] { @include cia.btn(ghost); margin-inline-start: auto; }
[data-slot="cancel"] { @include cia.btn(ghost); }
[data-slot="confirm"] { @include cia.btn(primary); }
}
/* Backdrop is exposed by the browser via ::backdrop */
.my-dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
Interactivity
Native behavior of <dialog>.showModal():
- Renders above all other content in the top-layer
- Adds
[open]andaria-modal="true" - Traps Tab focus inside the dialog
- Dismisses on Esc (calls
.close()with noreturnValue) - Disables interaction with the page behind it
- Exposes
::backdropfor backdrop styling
To open: dialogEl.showModal(). To close: dialogEl.close(optionalReturnValue). The return value is readable on the close event for "Cancel vs Confirm" wiring.
Consumer responsibilities:
- Show / close from your component logic (button clicks, route changes, etc.)
- Optionally listen for the
closeevent to readreturnValue - If using
<form method="dialog">, submit buttons close withreturnValue = button.value
No JS shim required for any of the above — every behavior is native.
A11y checklist
-
aria-labelledbypoints to the title element (WAI-ARIA Authoring Practices: Dialog (Modal)) - Initial focus on the primary action via
autofocus, OR moved programmatically to the first focusable element on open - Background content has
inertattribute when dialog is open (automatic with.showModal()— the top-layer hides everything from accessibility tree) - Esc closes the dialog (native to
<dialog>.showModal()) - Close button has accessible name (
aria-label="Close") - Focus returns to the trigger element on close (browser handles this automatically when
.showModal()was the open mechanism) - If dialog contains a
<form>, usemethod="dialog"so submission closes the dialog naturally (avoids hand-wired close handlers) - Color contrast on
::backdropdoes not interfere with the dialog's own contrast budget (WCAG 2.2 SC 1.4.11 Non-text Contrast)
Framework examples
React
"use client";
import { useRef } from "react";
import styles from "./MyDialog.module.scss";
export default function MyDialog() {
const ref = useRef<HTMLDialogElement>(null);
return (
<>
<button onClick={() => ref.current?.showModal()}>Open dialog</button>
<dialog
ref={ref}
className={styles.myDialog}
aria-labelledby="my-dialog-title"
onClose={(e) => {
// e.currentTarget.returnValue is the close value
}}
>
<header data-slot="header">
<h2 id="my-dialog-title">Delete record?</h2>
<button data-slot="close" formMethod="dialog" aria-label="Close">×</button>
</header>
<main data-slot="body">This action cannot be undone.</main>
<footer data-slot="footer">
<form method="dialog" style={{ display: "contents" }}>
<button data-slot="cancel" value="cancel">Cancel</button>
<button data-slot="confirm" value="confirm" autoFocus>Delete</button>
</form>
</footer>
</dialog>
</>
);
}
Vue
<script setup>
import { ref } from "vue";
const dialogRef = ref(null);
const open = () => dialogRef.value?.showModal();
</script>
<template>
<button @click="open">Open dialog</button>
<dialog
ref="dialogRef"
class="my-dialog"
aria-labelledby="my-dialog-title"
>
<header data-slot="header">
<h2 id="my-dialog-title">Delete record?</h2>
<button data-slot="close" formmethod="dialog" aria-label="Close">×</button>
</header>
<main data-slot="body">This action cannot be undone.</main>
<footer data-slot="footer">
<form method="dialog" style="display: contents">
<button data-slot="cancel" value="cancel">Cancel</button>
<button data-slot="confirm" value="confirm" autofocus>Delete</button>
</form>
</footer>
</dialog>
</template>
Svelte
<script>
let dialogEl;
const open = () => dialogEl?.showModal();
</script>
<button on:click={open}>Open dialog</button>
<dialog
bind:this={dialogEl}
class="my-dialog"
aria-labelledby="my-dialog-title"
>
<header data-slot="header">
<h2 id="my-dialog-title">Delete record?</h2>
<button data-slot="close" formmethod="dialog" aria-label="Close">×</button>
</header>
<main data-slot="body">This action cannot be undone.</main>
<footer data-slot="footer">
<form method="dialog" style="display: contents">
<button data-slot="cancel" value="cancel">Cancel</button>
<button data-slot="confirm" value="confirm" autofocus>Delete</button>
</form>
</footer>
</dialog>
Vanilla (Web Component)
class MyDialog extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<dialog aria-labelledby="my-dialog-title">
<header data-slot="header">
<h2 id="my-dialog-title">${this.getAttribute("title") ?? "Dialog"}</h2>
<button data-slot="close" formmethod="dialog" aria-label="Close">×</button>
</header>
<main data-slot="body"><slot></slot></main>
<footer data-slot="footer">
<form method="dialog" style="display: contents">
<button data-slot="cancel" value="cancel">Cancel</button>
<button data-slot="confirm" value="confirm" autofocus>OK</button>
</form>
</footer>
</dialog>
`;
this._dialog = this.querySelector("dialog");
}
open() { this._dialog?.showModal(); }
close(returnValue) { this._dialog?.close(returnValue); }
}
customElements.define("my-dialog", MyDialog);
// usage in HTML:
// <my-dialog title="Delete record?">This action cannot be undone.</my-dialog>
// <script>document.querySelector('my-dialog').open();</script>
Variants
Confirm / cancel only (no header dismiss)
Drop the [data-slot="close"] button from the header. Esc still closes; the cancel button in the footer is the primary dismiss.
Drawer (slide-in from edge)
Override .my-dialog with inset-inline-end: 0; margin-inline: auto 0; block-size: 100dvh; max-block-size: none; to anchor right edge, full height. Add a slide animation:
// Duration + easing come from the theme, so the drawer inherits each theme's
// feel (Terminal snappy, Glass floaty) with no code change.
.my-dialog[open] {
animation: slide-in var(--duration-normal, 240ms) var(--ease, ease) both;
@media (prefers-reduced-motion: reduce) {
animation-duration: 0.01ms;
}
}
@keyframes slide-in {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
cia's built-in keyframes are small entrance nudges, so a full-width drawer needs its own @keyframes. If an 8px slide is enough, skip the custom keyframe and use @include cia.animate(slide-left); — it reads the same duration/easing tokens and handles prefers-reduced-motion for you.
Bottom sheet on phones
At phone widths a centered modal can feel cramped; the mobile-app convention is a bottom sheet. cia.sheet gives <dialog> the sheet's box styling — full-width at the bottom edge, 72dvh height cap, rounded shoulders, home-indicator safe-area padding — but its slide-up motion is built for [popover] elements: both the entry transition and the closed-state off-screen offset key on :popover-open, which a <dialog> never matches. So on <dialog> you must reset that offset for the [open] state, and the sheet opens in place instead of sliding:
.my-dialog {
@include cia.modal;
@include cia.media-down(md) {
@include cia.sheet; // box styles only on <dialog> — see note above
&[open] {
translate: 0 0; // undo the sheet's :popover-open-keyed offset
}
}
}
If the slide-up entrance matters to you, use a [popover] element with the bottom-nav recipe's sheet pattern instead of <dialog> — you trade .showModal()'s focus trap for light dismiss and the full animation.
Pitfalls
- Form inside dialog with another method: Setting
<form method="get">etc. inside a<dialog>breaks the native close-on-submit. Either nest a separate<form method="dialog">around the close buttons OR handle submission explicitly withevent.preventDefault()+dialogEl.close(). - Stacking dialogs:
.showModal()puts the dialog in the top-layer. Nesting multiple modal dialogs works but each pushes the previous one further from focus; consider whether your UX really needs nested modals. - iOS Safari quirks: Older iOS Safari (< 17) had
::backdroprendering bugs and inconsistent scroll-lock. If you target older Safari, test the backdrop and consider a polyfill. <dialog>insidedisplay: contentsancestors: Some CSS layout edge cases can affect the top-layer rendering. If positioning looks off, check that ancestortransform,filter, orperspectiveisn't establishing a containing block.
Related recipes
bare-tags— base bare<dialog>styling that applies if you skip a custom class namebottom-nav— slide-up sheets on[popover]with the full animation; the alternative named in the bottom-sheet variant above- (planned v1.1)
command-palette.md— Cmd+K palette built on<dialog>+ combobox pattern - (planned v1.1)
toast.md— non-modal transient notifications ([popover]based)