Skip to content

Chapter 47: Building kit-dialog

Modals are one of the most frequently rebuilt controls on the web. They’re also one of the easiest to get wrong.

Focus management. Focus restoration when the modal closes. Escape-to-close behavior. Click-outside-to-close (or not, depending on the dialog’s purpose). Background interaction prevention. Scroll locking on the body. Top-layer rendering so the modal isn’t cut off by overflow: hidden or stacking context. Accessible labelling. Screen-reader announcement on open. Keyboard focus trap. Backdrop dimming. Animation in and out. Nested-dialog stacking.

For most of the web’s history, every team built every modal from scratch, and most of them got at least one of these wrong. The platform’s <dialog> element, with reliable focus management since 2022, makes the whole class of problems substantially easier.

kit-dialog wraps native <dialog>. The component adds visual styling, slots for the dialog’s regions, integration with the metadata protocol, and a small command surface for opening and closing. Everything else — focus management, Escape handling, top-layer rendering, accessible labelling — comes from the platform.

A native <dialog> element, opened with dialog.showModal(), gives the application:

Top-layer rendering. The dialog renders above everything else in the page, regardless of stacking contexts or overflow rules. The platform handles the rendering at a layer above the normal DOM, so the dialog is always visible.

Modal focus trap. When opened with showModal(), focus is automatically trapped inside the dialog. Tab cycles through the dialog’s focusable elements. Shift+Tab cycles backward. Focus can’t escape the dialog without closing it.

Initial focus. When the dialog opens, focus moves to the first focusable element inside it (or to the dialog itself if no focusable elements are present, or to an element with the autofocus attribute if one is specified).

Focus restoration. When the dialog closes, focus returns to the element that triggered it.

Escape-to-close. Pressing Escape closes a modal dialog automatically.

The backdrop pseudo-element. The ::backdrop pseudo-element styles the dimmed background behind the dialog. CSS can target it directly.

Inert background. While the modal is open, the rest of the page is inert — clicks and keyboard navigation can’t reach it. Screen readers announce only the dialog’s content.

Close events. The dialog fires close (after closing) and cancel (when closed by Escape) events.

The open attribute. Reflected on the element when the dialog is open. CSS can target [open].

The platform handles each of these. For most modal use cases, using <dialog> directly is sufficient — the only reason to wrap it is for the application’s visual styling, the metadata protocol participation, and the convenience methods.

A working kit-dialog:

<kit-dialog id="settings-dialog" labelled-by="settings-title">
<h2 slot="title" id="settings-title">Settings</h2>
<div slot="body">
<p>Configure your application preferences.</p>
<kit-input label="Display name" value="Jeremy"></kit-input>
</div>
<div slot="footer">
<kit-button variant="ghost"
meta-command="dialog.close"
meta-prop-target="settings-dialog">
Cancel
</kit-button>
<kit-button variant="primary" meta-event="settings.saved">
Save
</kit-button>
</div>
</kit-dialog>

The component exposes:

  • id — used by the dialog module to find the dialog for dialog.open and dialog.close commands.
  • labelled-by — references the element that names the dialog (for the accessibility tree).
  • open — reflected when the dialog is open.

Slots:

  • title — the dialog’s heading.
  • body — the dialog’s content.
  • footer — the dialog’s action buttons.

Methods:

  • open() — programmatically open the dialog (calls native showModal()).
  • close(returnValue?) — programmatically close the dialog with an optional return value.

Events:

  • kit-dialog:opened — composed event fired when the dialog opens.
  • kit-dialog:closed — composed event fired when the dialog closes, with the close reason in detail.
import { LitElement, html, css, type PropertyValues } from 'lit'
import { property, query } from 'lit/decorators.js'
class KitDialog extends LitElement {
@property({ type: String, attribute: 'labelled-by' }) labelledBy = ''
@property({ type: Boolean, reflect: true }) open = false
@query('dialog') private nativeDialog!: HTMLDialogElement
static styles = css`
:host {
display: contents;
}
dialog {
padding: 0;
border: none;
border-radius: 8px;
box-shadow: 0 16px 48px rgba(0,0,0,0.2);
max-width: min(90vw, 32rem);
max-height: 80vh;
width: 100%;
background: var(--kit-surface, white);
color: var(--kit-on-surface, black);
view-transition-name: kit-dialog-root;
}
dialog::backdrop {
background: rgba(0,0,0,0.4);
backdrop-filter: blur(2px);
}
.container {
display: flex;
flex-direction: column;
max-height: inherit;
}
.title {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--kit-border, rgba(0,0,0,0.1));
}
.body {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--kit-border, rgba(0,0,0,0.1));
}
@media (prefers-reduced-motion: reduce) {
dialog { view-transition-name: none; }
}
`
render() {
return html`
<dialog
aria-labelledby=${this.labelledBy || nothing}
@close=${this.handleClose}
@cancel=${this.handleCancel}
>
<div class="container">
<div class="title">
<slot name="title"></slot>
</div>
<div class="body">
<slot name="body"></slot>
<slot></slot>
</div>
<div class="footer">
<slot name="footer"></slot>
</div>
</div>
</dialog>
`
}
updated(changed: PropertyValues) {
if (changed.has('open')) {
if (this.open && !this.nativeDialog.open) {
this.nativeDialog.showModal()
this.dispatchEvent(new CustomEvent('kit-dialog:opened', {
bubbles: true,
composed: true
}))
} else if (!this.open && this.nativeDialog.open) {
this.nativeDialog.close()
}
}
}
open_() {
this.open = true
}
close(returnValue?: string) {
if (returnValue !== undefined) {
this.nativeDialog.returnValue = returnValue
}
this.open = false
}
private handleClose() {
this.open = false
this.dispatchEvent(new CustomEvent('kit-dialog:closed', {
bubbles: true,
composed: true,
detail: { returnValue: this.nativeDialog.returnValue, reason: 'close' }
}))
}
private handleCancel() {
this.dispatchEvent(new CustomEvent('kit-dialog:closed', {
bubbles: true,
composed: true,
detail: { reason: 'cancel' }
}))
}
}
// Rename `open_` to `open` only if it doesn't conflict with the property name
;(KitDialog.prototype as any).openDialog = (KitDialog.prototype as any).open_
customElements.define('kit-dialog', KitDialog)

The component is roughly 100 lines. The actual work it adds on top of <dialog> is:

  • Slot-based composition (title, body, footer).
  • CSS custom-property-based theming.
  • Reactive open property that mirrors the dialog’s state.
  • Composed events (kit-dialog:opened, kit-dialog:closed) that work across shadow boundaries.
  • view-transition-name for animated open/close (when supported).

Everything else — focus trap, focus restoration, Escape handling, top-layer rendering, inert background — is the native <dialog> doing its job.

The most important architectural decision in kit-dialog is what it doesn’t implement.

The component doesn’t manage focus. It doesn’t trap Tab inside the dialog. It doesn’t restore focus when the dialog closes. It doesn’t intercept Escape. It doesn’t track which element triggered the dialog. None of this is in the component’s code.

The platform does it. dialog.showModal() traps focus. Closing the dialog restores focus to the trigger. Escape fires the cancel event. The browser handles every one of these behaviors natively.

This is the architectural payoff of using native elements. The component’s job is visual presentation and integration, not recreating modal semantics. The platform’s contracts (Chapter 29) apply because the platform’s element is doing the work.

A custom <div>-based modal would have to implement each of these manually. The implementation would be subtle, full of edge cases (what if the trigger is no longer in the DOM when the modal closes? what if a nested modal opens? what if the user opens the dialog with the keyboard vs. the mouse?), and most teams would get at least one detail wrong. The <dialog>-based component avoids all of this.

The component declares view-transition-name: kit-dialog-root on the inner dialog. When the application has cross-document or same-document View Transitions enabled (Chapter 28), the open and close animations happen automatically.

@view-transition {
navigation: auto;
}
::view-transition-old(kit-dialog-root) {
animation: 200ms ease-out both fade-out, 200ms ease-out both shrink;
}
::view-transition-new(kit-dialog-root) {
animation: 200ms ease-out both fade-in, 200ms ease-out both grow;
}

The animations interpolate between the dialog’s closed (not in the DOM) and open (in the top layer) states. The browser handles the timing, the easing, and the rendering. The component just declares the participation name.

For applications without View Transitions support (or in prefers-reduced-motion contexts), the dialog opens and closes instantly. The component degrades gracefully because the animation is layered on top, not built in.

The metadata-command integration uses a small dialog module that handles dialog.open and dialog.close commands:

const dialogModule = defineKitModule({
name: 'dialog',
commands: {
'dialog.open': (command) => {
const target = (command.payload as any)?.target
if (!target) return { ok: false, reason: 'missing-target' }
const dialog = document.getElementById(target) as KitDialog | null
if (!dialog) return { ok: false, reason: 'dialog-not-found' }
if (typeof dialog.open !== 'boolean') return { ok: false, reason: 'not-a-dialog' }
dialog.open = true
return { ok: true }
},
'dialog.close': (command) => {
const target = (command.payload as any)?.target
const returnValue = (command.payload as any)?.returnValue
const dialog = document.getElementById(target) as KitDialog | null
if (!dialog || typeof dialog.open !== 'boolean') {
return { ok: false, reason: 'dialog-not-found' }
}
dialog.close(returnValue)
return { ok: true }
}
}
})

A button can now open a dialog without importing the dialog code:

<kit-button meta-command="dialog.open" meta-prop-target="settings-dialog">
Open settings
</kit-button>

The button declares the command. The metadata boundary observes the click. The runtime dispatches dialog.open. The dialog module finds the dialog and opens it. Decoupled at every step.

The native <dialog> element handles nested dialogs through the top-layer stack. Opening a dialog from inside another dialog creates a new top-layer entry above the previous one. The Escape key closes the topmost dialog, not the bottommost. The platform manages the stack.

For most applications, this works correctly without further configuration. For complex applications (a dialog that opens a confirmation dialog, that opens another confirmation dialog), the stack can get deep, and the platform handles each level.

A pattern worth knowing: the kit-dialog:closed event with reason: 'cancel' fires when the user pressed Escape. The application can decide whether to confirm or discard changes based on the close reason.

The next chapter (Chapter 48) covers disclosure (using <details> / <summary>), the modern customizable <select>, and the popover API. The pattern repeats: native primitive underneath, Lit for authoring, accessibility preserved, metadata participation. After Chapter 48, the Kit component library has the major interactive controls.

Chapter 49 then covers forms and form-associated custom elements — the more elaborate cousins of buttons that need full participation in form submission. Chapter 50 covers styling — design tokens, cascade layers, container queries applied to the component library. Chapter 51 ties everything together with the design-systems argument.

Implement kit-dialog following the pattern in this chapter. Build:

  1. The component with labelled-by and open properties, plus open() and close() methods.
  2. The internal native <dialog> rendered with aria-labelledby and @close and @cancel listeners.
  3. Slots for title, body, footer, and default content.
  4. CSS custom properties for surface color, border, and theming.
  5. View Transition naming for animated open/close.

Then test each behavior:

  1. Open via method: Call dialog.open() in JavaScript. Verify it opens.
  2. Close via Escape: Open the dialog. Press Escape. Verify it closes and the kit-dialog:closed event fires with reason: 'cancel'.
  3. Close via button: Add a button inside the dialog that closes it. Verify focus returns to the trigger when the dialog closes.
  4. Focus trap: Tab through the dialog’s focusable elements. Verify focus stays inside the dialog and cycles correctly.
  5. Background inert: While the dialog is open, try to click something behind the backdrop. Verify the click doesn’t reach the background.
  6. Screen reader: Open with VoiceOver or NVDA. Verify the dialog is announced as a dialog and the labelled-by reference is read.

Then integrate with the architecture:

  1. Implement the dialog module from this chapter and install it in your shell.
  2. Add a button outside the dialog with meta-command="dialog.open" and meta-prop-target="settings-dialog". Verify clicking the button opens the dialog.
  3. Add a Cancel button inside the dialog with meta-command="dialog.close". Verify it closes the dialog.
  4. Add a Save button inside the dialog with meta-event="settings.saved". Verify the event fires and a subscribed module observes it. (Bonus: close the dialog from the module after the save completes.)

Reflect on:

  1. How much of the dialog’s behavior came from the platform’s <dialog>? (Focus trap, focus restoration, Escape, inert background, top-layer rendering, accessibility.)
  2. How much came from Lit and the component’s code? (Slots, styling, the open/close API, the composed events.)
  3. If you tried to build this with a <div> and JavaScript focus management, how much more code would it take? (At least 200 lines of focus-management code, plus all the edge cases.)
  4. How does the architecture’s decoupling between the button (which declares dialog.open) and the dialog module (which handles it) compare to a callback-based approach?

The dialog is one of the clearest examples of the platform did the work. Recreating modal semantics in user-space is the kind of project that consumes weeks across multiple frontend tickets. Using native <dialog> with a thin wrapper is the kind of project that takes an afternoon. The architectural argument the book has been building lands particularly clearly here.