Building an Accessible Modal Dialog on the Native <dialog>

Want more of these in your Google results?
A modal dialog looks like a solved problem. You put a box on top of the page, dim what’s behind it, and give people a way to close it. Then it ships, and the bug reports come in: keyboard users can Tab to the links behind it, screen readers never announce it, the page scrolls underneath when you use a trackpad, and closing it drops focus back at the top of the document. Three of those are the component’s job to fix, and the fourth, the background scroll, is the app’s call that step 6 comes back to. The box is the easy part. The behavior that makes it usable, the focus handling and the keyboard and the screen reader support, is mostly invisible, and it’s where modals quietly fall apart.
None of that is new, and none of it is unsolved. The modal is one of the most studied patterns in interface design, partly because it’s so easy to get wrong. The Nielsen Norman Group has written for years that a modal interrupts the user and pulls the interface into a separate mode, which is powerful when you genuinely need someone’s attention and irritating when you don’t. The Baymard Institute, which runs large-scale usability testing on real e-commerce sites, keeps finding that overlays interrupt and frustrate people who just wanted to get on with the task. Building one carefully isn’t pedantry. The usability and accessibility of this small box decide whether people can finish what they came to do.
A lot of that invisible work isn’t ours to write anymore. The native <dialog> element, opened with showModal(), puts itself in the browser’s top layer, dims the page with a ::backdrop, makes the rest of the document inert, traps focus inside itself, closes on the Escape key, and gives focus back to whatever opened it. That used to be a stack of brittle JavaScript and a focus-trap library; now most of it’s a single method call.
So we’re not building this from scratch. We’re building on top of <dialog>, leaning on it for the hard parts and filling the gaps it leaves: wrapping it in a small Lit web component so it’s reusable and typed, giving it a clean API and a close event that reports how it closed, labeling it for screen readers, deciding when it should close on an outside click, and animating it without making anyone motion sick. By the end you’ll have a clear sense of which parts of a dialog the platform owns and which parts are still your call.
A quick note on letting AI code this
You should hand this to an AI, and most days you will. Describe a modal dialog and you get working code in seconds, which is genuinely useful. What an AI can’t do for you is decide what good looks like, and a dialog can run perfectly while sitting anywhere on a wide quality scale. The things that lift it up that scale are the list above: a real accessible name, focus that lands somewhere safe, a background that’s truly inert, a close behavior that suits the dialog, motion that eases off when someone asks. An AI builds whatever you ask it for. A vague “give me a modal” returns the generic version, the common answer; ask for focus on Cancel, a genuinely inert background, and a light dismiss only where a stray click is harmless, and it builds that just as readily.
The leverage is in knowing which version to ask for, and in seeing whether the answer measures up. Building one by hand, once, is how you earn that eye. After this you can let the AI write every dialog you ever need and still be the person who can tell a considered component from a generic one, and that judgment only grows more valuable as the models improve, because the better they get, the more the bottleneck becomes taste.
The bar we’re holding it to
- Opens from a trigger button, drops onto any page.
- Always closes with the Escape key and a visible close button the consuming markup provides, closes with a primary action inside it, and optionally closes on a click on the backdrop when that suits the dialog.
- While open, the rest of the page is inert, so keyboard focus stays inside the dialog.
- Focus moves into the dialog on open and returns to the trigger on close.
- A screen reader announces it as a dialog with a real name.
- It animates in and out, and doesn’t animate for anyone who’s asked for reduced motion.
A single flow to test against: tab to a Delete account button, press Enter, the dialog opens with focus on Cancel, press Escape, and focus lands back on Delete account.
What you’ll need
You’ll want to be comfortable with TypeScript and the DOM. No Lit experience required, since it’s a thin layer over web components and I’ll explain the Lit-specific bits inline. Beyond that: Lit and TypeScript, your browser’s devtools (the Accessibility panel for the dialog’s name and role, the Elements panel to see the top layer and ::backdrop), and a screen reader. VoiceOver on macOS or NVDA on Windows, and five minutes with one will teach you more than the rest of this article. No design tool, because there’s no visual design work here.
Try it yourself first
Give it a go before reading on. Start from <dialog> and showModal() instead of a div, and see how much you get for nothing. Then go looking for the gaps: click outside it and turn on a screen reader to hear what it announces. Those gaps are the actual work.
Building it
Step 1: Start from <dialog>, not a div
The most important decision comes before any component code: reach for the real <dialog> element instead of a <div>. In plain HTML and a few lines of TypeScript, that looks like this:
scratch.html
<!-- scratch.html -->
<button id="open">Delete account</button>
<dialog id="confirm">
<h2>Delete account?</h2>
<p>This cannot be undone.</p>
<button id="cancel">Cancel</button>
</dialog>scratch.ts
// scratch.ts
const $dialog = document.querySelector<HTMLDialogElement>( '#confirm' )!;
document.querySelector( '#open' )!.addEventListener( 'click', () => $dialog.showModal() );
document.querySelector( '#cancel' )!.addEventListener( 'click', () => $dialog.close() );showModal() is doing a lot of work in that one call. It moves the dialog into the browser’s top layer, so there’s no z-index arms race with the rest of your UI. It dims the page with a ::backdrop. It makes everything outside the dialog inert, traps focus inside it, wires up Escape to close, and restores focus to the button that opened it once it closes. Rebuilding all of that on a <div> is a genuinely hard afternoon, and it tends to be the kind of thing that looks fine while you test it with a mouse and falls over for the person using a screen reader. Start from <dialog> and you simply never write it.
Step 2: Wrap it in a documented web component
To use this on more than one page without copy-pasting markup, wrap it in a web component. I’m reaching for Lit here because it keeps the boilerplate small and its decorators make the element’s API easy to read. Two files from now on: the public types go in types.ts, and the component lives in index.ts.
Rather than drop the whole class at once, build it up one concern at a time. Start with the shell: the element, its TSDoc, and the tag-map entry that makes document.querySelector( 'app-dialog' ) resolve to the right type so the editor autocompletes the API for everyone who uses it.
index.ts
// index.ts
import { LitElement, html, css } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
/**
* An accessible modal dialog built on the native `<dialog>` element.
*
* Open it with {@link AppDialog.show} and close it with {@link AppDialog.close}, the Escape key, or any slotted button marked `data-close`.
* Set `dismissible` to also let a click outside close it.
* Emits a `dialog-close` event describing how it closed.
*
* @element app-dialog
* @slot - The dialog body and its action buttons.
* @fires dialog-close - Fired after the dialog closes, carrying an `AppDialogCloseDetail`.
* @cssprop [--app-dialog-radius=12px] - The dialog's corner radius.
*/
@customElement( 'app-dialog' )
export class AppDialog extends LitElement {
}
declare global {
interface HTMLElementTagNameMap {
'app-dialog': AppDialog;
}
}Next the public surface, two reactive properties. Each is a @property so Lit re-renders when it changes, and each carries a full TSDoc block that says why it’s shaped the way it is. Open and closed state isn’t one of them: that lives on the native <dialog>, so consumers drive the component through show() and close() rather than an open property.
ts
/**
* The dialog's accessible name.
* Rendered as its heading and announced by a screen reader when the dialog opens.
*/
@property( { type: String } )
heading = '';
/**
* Whether a click outside the dialog (a light dismiss) closes it.
*
* Defaults to `false`, because dismissing a confirmation by accident loses the user's place.
* Turn it on for low-stakes, informational dialogs.
*/
@property( { type: Boolean } )
dismissible = false;The layout styles, scoped to the shadow root. There’s almost nothing here on purpose, since the heavy lifting is the animation in step 7.
ts
/**
* Styles for the component, scoped to its shadow root.
*/
static styles = css`
dialog {
border: none;
border-radius: var(--app-dialog-radius, 12px);
padding: 1.5rem;
max-width: 32rem;
width: calc(100% - 2rem);
}
`;A reference to the rendered <dialog>, so the open and close methods have something to call. The @query decorator resolves it against the shadow root after the first render, which is fine here because render() always outputs the <dialog>, so by the time a user-triggered show() runs the element exists. Per house style the field is $-prefixed because it holds a DOM element.
ts
/**
* The native `<dialog>` element inside the shadow root, resolved by Lit after the first render.
*/
@query( 'dialog' )
private $dialog!: HTMLDialogElement;The two methods that open and close it. show() opens the dialog as a modal; close() asks the native element to close, passing along an optional return value the next step reads. Both guard the calls the native element is fussy about: show() awaits the first render and bails if the dialog is already open, since showModal() throws on an open dialog, and close() does nothing when it’s already closed. Neither keeps a separate open flag, because the native <dialog> is the source of truth for that, and every close, a button, Escape, or an outside click, comes back through its close event.
ts
/**
* Opens the dialog as a modal.
*
* Awaits the first render so it is safe to call early, and is a no-op if the dialog is already open, since `showModal()` throws on an open dialog.
*/
async show(): Promise<void> {
await this.updateComplete;
if ( this.$dialog.open ) {
return;
}
this.$dialog.showModal();
}
/**
* Closes the dialog with an optional return value.
*
* A no-op when the dialog is already closed, so a stray call is harmless.
*
* @param returnValue - How it closed, for example `'confirm'` or `'cancel'`.
*/
close( returnValue = '' ): void {
if ( ! this.$dialog?.open ) {
return;
}
this.$dialog.close( returnValue );
}Finally the template. The closedby attribute maps the dismissible property onto the native light-dismiss behavior (step 5 covers it), aria-labelledby points the dialog’s accessible name at the heading, and the @close listener, added in the next step, fires on every close so the component can report how it happened.
ts
/**
* Renders the dialog and projects the caller's content into it.
*/
render() {
return html`
<dialog
aria-labelledby="title"
closedby=${this.dismissible ? 'any' : 'closerequest'}
@close=${this.handleClose}
>
<h2 id="title">${this.heading}</h2>
<slot></slot>
</dialog>
`;
}That’s the skeleton: it opens, it closes, and its whole surface is typed and documented. The @close listener still needs a handler, and that’s where the next step picks up.
Step 3: Report how it closed
A dialog isn’t much use if it can’t tell you what the person decided. Did they confirm, or back out? The native element already tracks that in its returnValue, so we forward it in a typed custom event. The event payload gets its own type, in types.ts:
types.ts
// types.ts
/**
* The detail payload carried by the `dialog-close` event of `app-dialog`.
*/
export interface AppDialogCloseDetail {
/**
* How the dialog closed.
* Taken from the `value` of the button that closed it, or an empty string when it was closed by Escape or a light dismiss.
*/
returnValue: string;
}Now the @close listener gets its handler. handleClose runs on every close, the ones that call close() and the ones that don’t, an Escape press, an outside click, or a slotted button, since they all fire the native close event. It dispatches the typed event so any page can react. It’s composed: true so the event crosses the shadow boundary, and bubbles: true so a listener on the host element receives it.
index.ts
// index.ts, a new member on the class
import type { AppDialogCloseDetail } from './types';
/**
* Runs on every close (an Escape press, an outside click, or a slotted button) and dispatches the typed `dialog-close` event.
*/
private handleClose = (): void => {
this.dispatchEvent(
new CustomEvent<AppDialogCloseDetail>( 'dialog-close', {
detail: { returnValue: this.$dialog.returnValue },
bubbles: true,
composed: true,
} ),
);
};An imperative listener should read event.detail without a cast. Teach addEventListener the event’s type with a typed overload on the class, the same trick the tag-map entry uses for the element itself. It goes next to the other members in index.ts:
index.ts
// index.ts, a typed listener overload on the class
/**
* Types `addEventListener( 'dialog-close', ... )` so the listener receives a `CustomEvent<AppDialogCloseDetail>` with no cast.
* Falls through to the standard signature for every other event.
*/
addEventListener<K extends keyof AppDialogEventMap>(
type: K,
listener: ( this: AppDialog, ev: AppDialogEventMap[ K ] ) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener( type, listener, options );
}The AppDialogEventMap it leans on is a one-line extension of the built-in map, in types.ts:
types.ts
// types.ts
/**
* Event map for `app-dialog`, so a listener on the element is typed without a cast.
*/
export interface AppDialogEventMap extends HTMLElementEventMap {
'dialog-close': CustomEvent<AppDialogCloseDetail>;
}That overload types a hand-written addEventListener( 'dialog-close', ... ), so an imperative consumer reads event.detail with no cast: a reusable element shouldn’t assume its consumer reaches for a framework. In a Lit app, though, you usually consume app-dialog in a template and catch its result with @dialog-close, typing the handler with that same CustomEvent<AppDialogCloseDetail>:
account-panel.ts
// account-panel.ts, a Lit component that uses <app-dialog>
import { LitElement, html } from 'lit';
import { customElement, query } from 'lit/decorators.js';
import './app-dialog'; // registers <app-dialog>
import type { AppDialog } from './app-dialog';
import type { AppDialogCloseDetail } from './app-dialog/types';
@customElement( 'account-panel' )
export class AccountPanel extends LitElement {
/**
* The confirmation dialog rendered below, resolved by Lit after the first render.
*/
@query( 'app-dialog' )
private $dialog!: AppDialog;
/**
* Renders the trigger and the dialog, and catches its result with `@dialog-close`.
*/
render() {
return html`
<button @click=${this.openDialog}>Delete account</button>
<app-dialog heading="Delete account?" @dialog-close=${this.handleDialogClose}>
<!-- body and action buttons, added in step 4 -->
</app-dialog>
`;
}
/**
* Opens the confirmation dialog.
*/
private openDialog = (): void => {
void this.$dialog.show();
};
/**
* Deletes the account when the dialog closes with a confirmed result.
*
* @param event - The dialog's `dialog-close` event, typed without a cast.
*/
private handleDialogClose = ( event: CustomEvent<AppDialogCloseDetail> ): void => {
if ( event.detail.returnValue === 'confirm' ) {
// delete the account
}
};
}Step 4: Label it, and place focus on purpose
showModal() already traps focus and restores it on close, so the only thing left to decide is where focus should land when the dialog opens. Put it on the safe choice. For a destructive confirmation that means Cancel, not Delete, so a stray Enter doesn’t wipe someone’s account. The autofocus attribute handles that, and the heading wired to aria-labelledby back in step 2 gives the dialog its accessible name:
app.html
<!-- app.html -->
<app-dialog heading="Delete account?">
<p>This permanently removes your account and data. This cannot be undone.</p>
<div class="actions">
<button data-close value="cancel" autofocus>Cancel</button>
<button data-close value="confirm" class="danger">Delete</button>
</div>
</app-dialog>The component renders no buttons itself; the page supplies them in the slot. That keeps it flexible, but it hands the caller one accessibility job: the WAI-ARIA Authoring Practices want a visible close control in the dialog’s tab order, a close icon or a Cancel button, so the markup always includes one. Here it’s Cancel, which doubles as the safe initial focus.
Open the devtools accessibility panel and the node should report as a dialog named “Delete account?”. Skip the name and a screen reader just announces “dialog,” which tells the user nothing about what landed in front of them. For genuinely dangerous actions, the Nielsen Norman Group goes a step further: make the confirmation deliberate, for example by asking the person to type the account name, so deleting never becomes muscle memory.
Step 5: Decide how it closes
This is where most modal tutorials hand you a backdrop-click handler full of coordinate math. You don’t need it anymore. There are three close paths, and the platform owns most of each. Escape already works through showModal(). A click outside the dialog is now a single attribute, closedby: with closedby="any", the platform handles light dismiss for you, which is why the component maps it to the dismissible property.
The one close path you still wire up is the buttons. The platform almost has a native shortcut for it: a submit button inside a <form method="dialog"> closes the dialog and reports its value for free. It doesn’t reach us here, because the action buttons are slotted from the light DOM, and a form’s tie to its dialog is resolved by DOM ancestry, which doesn’t cross the shadow boundary into slotted content. So this is the one handler the platform can’t supply. Keep it deliberately narrow: a click on a slotted button you mark with data-close closes the dialog, using that button’s value as the return value, so an unrelated <button value="..."> elsewhere in the body never dismisses it by accident. Add @click=${this.handleClick} to the dialog in render():
index.ts
// index.ts, the dialog in render()
<dialog
aria-labelledby="title"
closedby=${this.dismissible ? 'any' : 'closerequest'}
@close=${this.handleClose}
@click=${this.handleClick}
>The handler reads the click’s composedPath(), which still lists the real button even across the shadow boundary, and finds the first one marked data-close. It skips a disabled button, then calls the component’s own close() rather than the native one, so a guard or some cleanup added to close() later covers this path too:
index.ts
// index.ts, a new member on the class
/**
* Closes the dialog when a slotted button marked `data-close` is clicked, unless that button is disabled, using its `value` as the return value.
* Routes through {@link AppDialog.close} so every close runs one path.
*
* @param event - A click originating inside the dialog.
*/
private handleClick = ( event: MouseEvent ): void => {
const $button = event
.composedPath()
.find( ( el ): el is HTMLButtonElement => el instanceof HTMLButtonElement && el.hasAttribute( 'data-close' ) );
if ( ! $button || $button.disabled ) {
return;
}
this.close( $button.value );
};A low-stakes dialog opts into light dismiss with a single attribute:
html
<!-- a low-stakes dialog that should close on an outside click -->
<app-dialog heading="Keyboard shortcuts" dismissible>
<!-- ...content... -->
</app-dialog>Browser data updated September 24, 2026
Notice that dismissible defaults to false. That default is deliberate. NN/G’s research on accidental dismissal of overlays found that letting an outside click or tap close a dialog regularly throws people out of a flow and loses their work, which is the last thing you want on a Delete account confirmation. So the default is Escape and the buttons, and you opt into light dismiss only where a stray click is harmless. Here, light dismiss is a progressive enhancement: where closedby isn’t supported yet (Safari today), the dialog just won’t light-dismiss, and Escape and the buttons still close it. If light dismiss is part of your product contract rather than a nicety, add a fallback or a polyfill instead of treating the attribute as everywhere.
Step 6: Leave scroll policy to the app
A modal <dialog> already owns modality, focus containment, Escape, and focus restoration. The one thing it doesn’t decide is whether the page behind it scrolls, and that’s genuinely the app’s call, not the dialog’s. Some products lock the page behind every modal. Others don’t, especially when the dialog is small, or the app has its own scroll container the lock would need to target.
A reusable component can’t know which of those is true, so this one doesn’t pretend to. The tempting move is to reflect an open attribute on the host just so the page can lock scroll with html:has(app-dialog[open]) { overflow: hidden }. It reads well in a demo, but it bakes a page-wide policy into a component that has no idea what the real scroll container is, and the attribute exists only to feed that one selector. It wouldn’t even survive the encapsulation: drop the reflected open and the only open left is the one showModal() sets on the inner <dialog> inside the shadow root, where a document-level :has() can’t reach across the shadow boundary to match it. So the dialog keeps its surface small and stays out of it. When a product does need the background locked, the app shell already owns the signal: it opens the dialog and hears every close through @dialog-close, so it can add a class on open, drop it on close, and lock with html.dialog-open { overflow: hidden }. That’s a page-level decision made where the real scroll container lives, and it needs nothing reflected on the component.
Step 7: Animate it with the three-state model
A dialog that pops in instantly feels cheap, but animating one used to be famously fiddly, because it toggles between display: none and display: block and you can’t transition display. Two CSS features fix that: transition-behavior: allow-discrete lets discrete properties like display and overlay take part in a transition, and @starting-style defines the values to animate from on the very first frame.
The clearest way to think about it is Chris Coyier’s three-state model: every in-and-out element has an on-the-way-in state, an open state, and an on-the-way-out state, and you style all three. With the :open pseudo-class, that maps cleanly onto the dialog, and it goes in the component’s static styles next to the layout from step 2:
index.ts
/* index.ts, inside static styles */
dialog {
transition:
opacity 0.2s ease,
translate 0.2s ease,
display 0.2s ease allow-discrete,
overlay 0.2s ease allow-discrete;
}
/* On the way out, and the resting closed state. */
dialog:not(:open) {
opacity: 0;
translate: 0 8px;
}
/* Open. */
dialog:open {
opacity: 1;
translate: 0 0;
}
/* On the way in. Must come last, since @starting-style adds no specificity and has to override the :open rule above. */
@starting-style {
dialog:open {
opacity: 0;
translate: 0 8px;
}
}
dialog::backdrop {
transition:
opacity 0.2s ease,
display 0.2s ease allow-discrete,
overlay 0.2s ease allow-discrete;
}
dialog:not(:open)::backdrop {
opacity: 0;
}
dialog:open::backdrop {
opacity: 1;
}
@starting-style {
dialog:open::backdrop {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
dialog,
dialog::backdrop {
transition: none;
}
}Browser data updated September 24, 2026
As of today @starting-style and transition-behavior: allow-discrete are Baseline across Chrome, Firefox, and Safari, so those two are safe to ship. The one to watch is the :open pseudo-class above, which only reached Baseline in May 2026 (Safari 26.5 was the last to ship it), so on an older Safari dialog:not(:open) { opacity: 0 } is dropped (:not() isn’t forgiving) and the dialog appears without the transition. That degradation is fine, but if you want the animation everywhere, swap :open for the [open] attribute selector, which showModal() also sets.
Two details that trip people up. The @starting-style block has to come last, because it adds no specificity and would otherwise lose to the :open rule. And the overlay transition goes on both the dialog and its ::backdrop: the ::backdrop’s own overlay transition is what keeps the backdrop in the top layer long enough to fade out instead of vanishing.
The prefers-reduced-motion block at the end isn’t a nicety. Some people get motion sick or have vestibular issues, and a panel sliding in is exactly the sort of thing that sets that off, so when they’ve told the system to reduce motion, we turn the transitions off.
Step 8: Put it together
That’s the whole component. The markup each page writes stays short, because all of the behavior lives in index.ts, and the page just declares its content with the markup from step 4. The assembled types.ts:
types.ts
// types.ts
/**
* The detail payload carried by the `dialog-close` event of `app-dialog`.
*/
export interface AppDialogCloseDetail {
/**
* How the dialog closed.
* Taken from the `value` of the button that closed it, or an empty string when it was closed by Escape or a light dismiss.
*/
returnValue: string;
}
/**
* Event map for `app-dialog`, so a listener on the element is typed without a cast.
*/
export interface AppDialogEventMap extends HTMLElementEventMap {
'dialog-close': CustomEvent<AppDialogCloseDetail>;
}And the assembled index.ts, folded here since you’ve already met every piece of it. Expand it to read the whole file or copy it:
index.ts
// index.ts
import { LitElement, html, css } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import type { AppDialogCloseDetail, AppDialogEventMap } from './types';
/**
* An accessible modal dialog built on the native `<dialog>` element.
*
* Open it with {@link AppDialog.show} and close it with {@link AppDialog.close}, the Escape key, or any slotted button marked `data-close`.
* Set `dismissible` to also let a click outside close it.
* Emits a `dialog-close` event describing how it closed.
*
* @element app-dialog
* @slot - The dialog body and its action buttons.
* @fires dialog-close - Fired after the dialog closes, carrying an `AppDialogCloseDetail`.
* @cssprop [--app-dialog-radius=12px] - The dialog's corner radius.
*/
@customElement( 'app-dialog' )
export class AppDialog extends LitElement {
/**
* The dialog's accessible name.
* Rendered as its heading and announced by a screen reader when the dialog opens.
*/
@property( { type: String } )
heading = '';
/**
* Whether a click outside the dialog (a light dismiss) closes it.
*
* Defaults to `false`, because dismissing a confirmation by accident loses the user's place.
* Turn it on for low-stakes, informational dialogs.
*/
@property( { type: Boolean } )
dismissible = false;
/**
* The native `<dialog>` element inside the shadow root, resolved by Lit after the first render.
*/
@query( 'dialog' )
private $dialog!: HTMLDialogElement;
/**
* Styles for the component, scoped to its shadow root.
*/
static styles = css`
dialog {
border: none;
border-radius: var(--app-dialog-radius, 12px);
padding: 1.5rem;
max-width: 32rem;
width: calc(100% - 2rem);
transition:
opacity 0.2s ease,
translate 0.2s ease,
display 0.2s ease allow-discrete,
overlay 0.2s ease allow-discrete;
}
/* On the way out, and the resting closed state. */
dialog:not(:open) {
opacity: 0;
translate: 0 8px;
}
/* Open. */
dialog:open {
opacity: 1;
translate: 0 0;
}
/* On the way in. Must come last, since @starting-style adds no specificity and has to override the :open rule above. */
@starting-style {
dialog:open {
opacity: 0;
translate: 0 8px;
}
}
dialog::backdrop {
transition:
opacity 0.2s ease,
display 0.2s ease allow-discrete,
overlay 0.2s ease allow-discrete;
}
dialog:not(:open)::backdrop {
opacity: 0;
}
dialog:open::backdrop {
opacity: 1;
}
@starting-style {
dialog:open::backdrop {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
dialog,
dialog::backdrop {
transition: none;
}
}
`;
/**
* Opens the dialog as a modal.
*
* Awaits the first render so it is safe to call early, and is a no-op if the dialog is already open, since `showModal()` throws on an open dialog.
*/
async show(): Promise<void> {
await this.updateComplete;
if ( this.$dialog.open ) {
return;
}
this.$dialog.showModal();
}
/**
* Closes the dialog with an optional return value.
*
* A no-op when the dialog is already closed, so a stray call is harmless.
*
* @param returnValue - How it closed, for example `'confirm'` or `'cancel'`.
*/
close( returnValue = '' ): void {
if ( ! this.$dialog?.open ) {
return;
}
this.$dialog.close( returnValue );
}
/**
* Types `addEventListener( 'dialog-close', ... )` so the listener receives a `CustomEvent<AppDialogCloseDetail>` with no cast.
* Falls through to the standard signature for every other event.
*/
addEventListener<K extends keyof AppDialogEventMap>(
type: K,
listener: ( this: AppDialog, ev: AppDialogEventMap[ K ] ) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener( type, listener, options );
}
/**
* Renders the dialog and projects the caller's content into it.
*/
render() {
return html`
<dialog
aria-labelledby="title"
closedby=${this.dismissible ? 'any' : 'closerequest'}
@close=${this.handleClose}
@click=${this.handleClick}
>
<h2 id="title">${this.heading}</h2>
<slot></slot>
</dialog>
`;
}
/**
* Runs on every close (an Escape press, an outside click, or a slotted button) and dispatches the typed `dialog-close` event.
*/
private handleClose = (): void => {
this.dispatchEvent(
new CustomEvent<AppDialogCloseDetail>( 'dialog-close', {
detail: { returnValue: this.$dialog.returnValue },
bubbles: true,
composed: true,
} ),
);
};
/**
* Closes the dialog when a slotted button marked `data-close` is clicked, unless that button is disabled, using its `value` as the return value.
* Routes through {@link AppDialog.close} so every close runs one path.
*
* @param event - A click originating inside the dialog.
*/
private handleClick = ( event: MouseEvent ): void => {
const $button = event
.composedPath()
.find( ( el ): el is HTMLButtonElement => el instanceof HTMLButtonElement && el.hasAttribute( 'data-close' ) );
if ( ! $button || $button.disabled ) {
return;
}
this.close( $button.value );
};
}
declare global {
interface HTMLElementTagNameMap {
'app-dialog': AppDialog;
}
}Try the finished component. It runs as a live example: the component in a sandboxed iframe, with its source shown and copyable beside it. Open it, close it all the ways, toggle reduced motion, and flip dismissible to watch it react:
import { LitElement, html, css } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import type { AppDialogCloseDetail, AppDialogEventMap } from './types';
/**
* An accessible modal dialog built on the native `<dialog>` element.
*
* Open it with {@link AppDialog.show} and close it with {@link AppDialog.close}, the Escape key, or any slotted button marked `data-close`.
* Set `dismissible` to also let a click outside close it.
* Emits a `dialog-close` event describing how it closed.
*
* @element app-dialog
* @slot - The dialog body and its action buttons.
* @fires dialog-close - Fired after the dialog closes, carrying an `AppDialogCloseDetail`.
* @cssprop [--app-dialog-radius=12px] - The dialog's corner radius.
* @attr reduced-motion - Demo affordance: forces the transitions off on top of the system `prefers-reduced-motion` setting.
*/
@customElement( 'app-dialog' )
export class AppDialog extends LitElement {
/**
* The dialog's accessible name.
* Rendered as its heading and announced by a screen reader when the dialog opens.
*/
@property( { type: String } )
heading = '';
/**
* Whether a click outside the dialog (a light dismiss) closes it.
*
* Defaults to `false`, because dismissing a confirmation by accident loses the user's place.
* Mapped onto the native `closedby` attribute, so the platform owns the light-dismiss behavior.
*/
@property( { type: Boolean } )
dismissible = false;
/**
* Forces the open and close transitions off, beyond the system `prefers-reduced-motion` setting.
*
* This is a live-example affordance so a reader can toggle reduced motion on demand.
* Production code does not need it, because the `@media (prefers-reduced-motion: reduce)` rule already covers the real setting.
*/
@property( { type: Boolean, reflect: true, attribute: 'reduced-motion' } )
reducedMotion = false;
/**
* The native `<dialog>` element inside the shadow root, resolved by Lit after the first render.
*/
@query( 'dialog' )
private $dialog!: HTMLDialogElement;
/**
* Styles for the component, scoped to its shadow root.
*/
static styles = css`
dialog {
border: none;
border-radius: var(--app-dialog-radius, 12px);
padding: 1.5rem;
max-width: 32rem;
width: calc(100% - 2rem);
background: var(--surface, #fff);
color: var(--ink, #17171a);
box-shadow: 0 24px 60px rgb(0 0 0 / 0.28);
transition:
opacity 0.2s ease,
translate 0.2s ease,
display 0.2s ease allow-discrete,
overlay 0.2s ease allow-discrete;
}
dialog:not(:open) {
opacity: 0;
translate: 0 8px;
}
dialog:open {
opacity: 1;
translate: 0 0;
}
@starting-style {
dialog:open {
opacity: 0;
translate: 0 8px;
}
}
dialog::backdrop {
background: rgb(0 0 0 / 0.42);
transition:
opacity 0.2s ease,
display 0.2s ease allow-discrete,
overlay 0.2s ease allow-discrete;
}
dialog:not(:open)::backdrop {
opacity: 0;
}
dialog:open::backdrop {
opacity: 1;
}
@starting-style {
dialog:open::backdrop {
opacity: 0;
}
}
:host([reduced-motion]) dialog,
:host([reduced-motion]) dialog::backdrop {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
dialog,
dialog::backdrop {
transition: none;
}
}
h2 {
margin: 0 0 0.6rem;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
}
`;
/**
* Opens the dialog as a modal.
*
* Awaits the first render so it is safe to call early, and is a no-op if the dialog is already open, since `showModal()` throws on an open dialog.
*/
async show(): Promise<void> {
await this.updateComplete;
if ( this.$dialog.open ) {
return;
}
this.$dialog.showModal();
}
/**
* Closes the dialog with an optional return value.
*
* A no-op when the dialog is already closed, so a stray call is harmless.
*
* @param returnValue - How it closed, for example `'confirm'` or `'cancel'`.
*/
close( returnValue = '' ): void {
if ( ! this.$dialog?.open ) {
return;
}
this.$dialog.close( returnValue );
}
/**
* Types `addEventListener( 'dialog-close', ... )` so the listener receives a `CustomEvent<AppDialogCloseDetail>` with no cast.
* Falls through to the standard signature for every other event.
*/
addEventListener<K extends keyof AppDialogEventMap>(
type: K,
listener: ( this: AppDialog, ev: AppDialogEventMap[ K ] ) => void,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void {
super.addEventListener( type, listener, options );
}
/**
* Renders the dialog and projects the caller's content into it.
*/
render() {
return html`
<dialog
aria-labelledby="title"
closedby=${this.dismissible ? 'any' : 'closerequest'}
@close=${this.handleClose}
@click=${this.handleClick}
>
<h2 id="title">${this.heading}</h2>
<slot></slot>
</dialog>
`;
}
/**
* Runs on every close (an Escape press, an outside click, or a slotted button) and dispatches the typed `dialog-close` event.
*/
private handleClose = (): void => {
this.dispatchEvent(
new CustomEvent<AppDialogCloseDetail>( 'dialog-close', {
detail: { returnValue: this.$dialog.returnValue },
bubbles: true,
composed: true,
} ),
);
};
/**
* Closes the dialog when a slotted button marked `data-close` is clicked, unless that button is disabled, using its `value` as the return value.
* Routes through {@link AppDialog.close} so every close runs one path.
*
* @param event - A click originating inside the dialog.
*/
private handleClick = ( event: MouseEvent ): void => {
const $button = event
.composedPath()
.find( ( el ): el is HTMLButtonElement => el instanceof HTMLButtonElement && el.hasAttribute( 'data-close' ) );
if ( ! $button || $button.disabled ) {
return;
}
this.close( $button.value );
};
}
declare global {
interface HTMLElementTagNameMap {
'app-dialog': AppDialog;
}
}Step back and the division of labor is the real takeaway. <dialog> and showModal() gave us the top layer, the focus trap, Escape, and focus restoration. closedby, :open, and @starting-style gave us light dismiss and the animation hooks with very little JavaScript. What we added was the typed API, the accessible name, the deliberate initial focus, the decision about whether an outside click should close, the motion design, and a clear line that page scroll policy belongs to the app, not the component. Knowing which side of that line a problem sits on, and checking the current support rather than guessing, is most of what makes this kind of work quick instead of fragile.
A few ways this goes wrong
- Building on a
<div>instead of<dialog>. You inherit the focus trap, the layering, and the Escape handling, and you’ll get at least one of them wrong. Use a div only when you have a concrete reason the element can’t do the job. - No accessible name. Without
aria-labelledby, a screen reader announces “dialog” and nothing else. Point it at the heading. - Light-dismissing a destructive dialog. Turning on an outside-click close everywhere feels helpful, but on a confirmation or a half-filled form it throws people out and loses their work. Reserve light dismiss for low-stakes dialogs.
- Baking scroll policy into the component. A reusable dialog can’t know the app’s real scroll container, so locking page scroll from inside it guesses wrong as often as right. If the product needs the background locked, do it in the app shell where that decision is informed.
- An animation that never runs. Transitioning an element that toggles
display: nonedoes nothing withoutallow-discreteand@starting-style, and the@starting-styleblock has to come last. Leave any of that out and the dialog just snaps.
What separates a senior from a junior here
- A promise-based confirm API. Wrap the dialog so a caller can
await dialog.confirm()and get a boolean back. Turning an event into something you can await is a small design move that pays off across a whole codebase. - Drawing the API boundary on purpose. Don’t add a public or reflected property just to enable a clever selector. Let the native dialog own open and closed state, and keep app-level concerns like scroll policy outside the reusable component.
- Tests that assert behavior, not markup. Cover all the close paths, the focus return, and the value carried by the close event. Those are the parts that break silently in a refactor.
- A documented, typed public API. The TSDoc and the tag-map entry mean the next person gets autocomplete and inline docs instead of pinging you to ask how the thing works.
Build your own
You’ve played with the finished component above. Now make one yourself, and test it the way it’ll actually get used: keyboard only, hands off the mouse, screen reader on. That last five minutes is where you find out whether it’s done.
Want more of these in your Google results?