Skip to content

Before you use this component

  • Install bits-ui@^2.19.3.
  • Requires client-side JavaScript to work.
Download ZIP

Dark surface palette · 9.0 KB ZIP File receipt View as Markdown View code

Preview

Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.

Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
cmp_modal_dialog_01 · version 1.0.0 · Dark surface palette
Using the PageSugar MCP server, fetch component cmp_modal_dialog_01 version 1.0.0 with variant "dark", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.

Not connected yet? Set up the MCP server

Code

Palette
Dark surface
Version
1.0.0
Digest
Full digest
sha256-d55606fb62ba63d9543965665bb6b41afd62e9f93b4deafd6bdb8ddbb3f6972d
ModalDialog.svelte Svelte · 13.8 KB Raw
<script lang="ts" module>
	import type { Snippet } from 'svelte';

	export type ModalDialogSize = 'sm' | 'md' | 'lg' | 'full';

	/** What the body and footer snippets receive: close the dialog from inside it. */
	export interface ModalDialogContext {
		close: () => void;
	}

	const TABBABLE =
		'input:not([type="hidden"]),select,textarea,button,a[href],[contenteditable="true"],[tabindex]:not([tabindex="-1"])';

	/** Whether a keyboard user could reach `element`: not disabled, inert, hidden or folded away. */
	function reachable(element: HTMLElement): boolean {
		if (element.matches(':disabled') || element.closest('[inert], [hidden]')) return false;
		if (element.closest('details:not([open]) > :not(summary)')) return false;
		if (element.checkVisibility) return element.checkVisibility({ visibilityProperty: true });
		return true;
	}

	/** The first control a keyboard user could reach inside `root`. */
	function firstTabbable(root: HTMLElement): HTMLElement | null {
		for (const element of root.querySelectorAll<HTMLElement>(TABBABLE)) {
			if (reachable(element)) return element;
		}
		return null;
	}

	/*
	 * One inert manager per document, shared by every open dialog. The newest open dialog's node
	 * at the end of <body> stays live and every other child of <body> is inert, including nodes
	 * added while it is open, so a dialog opened from inside another hands back to it on close.
	 * Only nodes marked here are released; anything the page made inert itself stays inert.
	 */
	const openDialogs: Element[] = [];
	const marked: Element[] = [];
	let watcher: MutationObserver | null = null;

	function applyInert() {
		const top = openDialogs.at(-1);
		for (const child of document.body.children) {
			if (!top || child === top) {
				const index = marked.indexOf(child);
				if (index < 0) continue;
				marked.splice(index, 1);
				child.removeAttribute('inert');
			} else if (!child.hasAttribute('inert')) {
				child.setAttribute('inert', '');
				marked.push(child);
			}
		}
		if (!top) {
			for (const element of marked.splice(0)) element.removeAttribute('inert');
		}
	}

	/** Make everything but `node` inert until the returned release is called. */
	function holdInert(node: Element): () => void {
		openDialogs.push(node);
		if (!watcher && typeof MutationObserver !== 'undefined') {
			watcher = new MutationObserver(applyInert);
			watcher.observe(document.body, { childList: true });
		}
		applyInert();
		return () => {
			const index = openDialogs.lastIndexOf(node);
			if (index >= 0) openDialogs.splice(index, 1);
			if (openDialogs.length === 0) {
				watcher?.disconnect();
				watcher = null;
			}
			applyInert();
		};
	}
</script>

<script lang="ts">
	import { Dialog } from 'bits-ui';

	interface Props {
		/** Whether the dialog is open. Bindable. */
		open: boolean;
		/** The dialog's heading; it also names the dialog for assistive technology. */
		title: string;
		/** One or two sentences under the title; it also describes the dialog. */
		description?: string;
		/** The task: a form, details or settings. Scrolls on its own when it is long. */
		children?: Snippet<[ModalDialogContext]>;
		/** Actions, secondary first and primary last. Set in a raised band at the foot. */
		footer?: Snippet<[ModalDialogContext]>;
		/** A button that opens the dialog; spread `props` on it so it is wired up. */
		trigger?: Snippet<[{ props: Record<string, unknown> }]>;
		/** Panel width. `full` is a large workspace, and a full-screen sheet below 640 px. */
		size?: ModalDialogSize;
		/** Whether a press on the backdrop closes the dialog. */
		closeOnOutsideClick?: boolean;
		/**
		 * Whether the visitor can dismiss the dialog (close button, Escape, backdrop). Set it
		 * false while a submission is pending; the `close` passed to the snippets still works.
		 */
		dismissible?: boolean;
		/** CSS selector, inside the dialog, of the element to focus when it opens. */
		initialFocus?: string;
		/** Accessible name of the close button. */
		closeLabel?: string;
		/** Called when the trigger, a dismissal or a snippet's close opens or closes it; not when `open` is set from outside. */
		onOpenChange?: (open: boolean) => void;
		/** Called on Escape; call `event.preventDefault()` to keep the dialog open. */
		onEscapeKeydown?: (event: KeyboardEvent) => void;
	}

	let {
		open = $bindable(false),
		title,
		description,
		children,
		footer,
		trigger,
		size = 'md',
		closeOnOutsideClick = true,
		dismissible = true,
		initialFocus,
		closeLabel = 'Close',
		onOpenChange,
		onEscapeKeydown
	}: Props = $props();

	let portalNode = $state<HTMLElement | null>(null);
	let panel = $state<HTMLElement | null>(null);
	let body = $state<HTMLElement | null>(null);
	let bodyContent = $state<HTMLElement | null>(null);

	/* The header's hairline appears only once the body has scrolled beneath it. */
	let scrolledPast = $state(false);
	/* A body that scrolls joins the tab order so it can be scrolled from the keyboard. */
	let overflowing = $state(false);

	function setOpen(next: boolean) {
		if (open === next) return;
		open = next;
		onOpenChange?.(next);
	}

	function close() {
		setOpen(false);
	}

	const context: ModalDialogContext = { close };

	function measure() {
		if (!body) return;
		scrolledPast = body.scrollTop > 0;
		overflowing = body.scrollHeight > body.clientHeight + 1;
	}

	/* The body and its content are both watched, so content that grows later re-measures too. */
	$effect(() => {
		const elements = [body, bodyContent].filter((element) => element !== null);
		if (!elements.length || typeof ResizeObserver === 'undefined') return;
		const observer = new ResizeObserver(measure);
		for (const element of elements) observer.observe(element);
		measure();
		return () => observer.disconnect();
	});

	/*
	 * While open, the page behind is inert, so assistive technology cannot wander into it. This
	 * runs before Bits UI's effects, so the opener is focusable again when focus returns to it.
	 */
	$effect.pre(() => {
		const node = portalNode;
		if (!open || !node) return;
		return holdInert(node);
	});

	/* Focus the named element, else the first control in the body, else the panel itself. */
	function onOpenAutoFocus(event: Event) {
		if (!panel) return;
		event.preventDefault();
		const named = initialFocus ? panel.querySelector<HTMLElement>(initialFocus) : null;
		for (const target of [named, body ? firstTabbable(body) : null]) {
			if (!target) continue;
			target.focus({ preventScroll: target !== named });
			if (document.activeElement === target) return;
		}
		panel.focus({ preventScroll: true });
	}

	function escapeKeydown(event: KeyboardEvent) {
		onEscapeKeydown?.(event);
		if (!dismissible) event.preventDefault();
	}

	function interactOutside(event: PointerEvent) {
		if (!closeOnOutsideClick || !dismissible) event.preventDefault();
	}

	function closeFromButton() {
		if (dismissible) close();
	}

	/*
	 * Widths step with the task. Below 640 px every panel keeps a 16 px margin, except full,
	 * which becomes a full-screen sheet with no corners and the footer clear of the home bar.
	 */
	const sizeClass: Record<ModalDialogSize, string> = {
		sm: 'sm:max-w-sm',
		md: 'sm:max-w-lg',
		lg: 'sm:max-w-2xl',
		full: 'max-sm:h-full max-sm:max-h-none max-sm:w-full max-sm:rounded-none sm:h-[calc(100dvh-4rem)] sm:max-w-5xl'
	};

	/* Opening settles in over 220 ms; closing is quicker and leaves on the exit curve. */
	const panelClass =
		'fixed inset-0 z-50 m-auto flex h-fit max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] flex-col overflow-hidden rounded-2xl bg-[var(--_surface)] text-[var(--_ink)] shadow-[0_0_0_1px_var(--_hairline),0_10px_15px_-3px_rgb(0_0_0/0.08),0_25px_50px_-12px_rgb(0_0_0/0.18)] [color-scheme:var(--_scheme)] outline-none transition-[opacity,scale,translate] duration-[220ms] ease-[cubic-bezier(.16,1,.3,1)] data-[ending-style]:scale-[.98] data-[ending-style]:opacity-0 data-[ending-style]:duration-150 data-[ending-style]:ease-[cubic-bezier(.4,0,1,1)] data-[starting-style]:scale-[.96] data-[starting-style]:opacity-0 motion-reduce:data-[ending-style]:scale-100 motion-reduce:data-[starting-style]:scale-100 sm:max-h-[calc(100dvh-4rem)] sm:w-[calc(100%-4rem)]';
	const overlayClass =
		'fixed inset-0 z-50 bg-[var(--_backdrop)] transition-opacity duration-[220ms] ease-[cubic-bezier(.16,1,.3,1)] data-[ending-style]:opacity-0 data-[ending-style]:duration-150 data-[ending-style]:ease-[cubic-bezier(.4,0,1,1)] data-[starting-style]:opacity-0';
	/* 44 px for a finger, 36 px under a mouse; the icon's end edge lines up with the body text. */
	const closeClass =
		'inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-[var(--_muted)] transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)] hover:bg-[var(--_fill)] hover:text-[var(--_ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--_accent)] active:bg-[var(--_fill-pressed)] active:duration-[80ms] aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:bg-transparent aria-disabled:hover:text-[var(--_muted)] pointer-fine:size-9';
</script>

<Dialog.Root bind:open={() => open, setOpen}>
	{#if trigger}
		<Dialog.Trigger>
			{#snippet child({ props })}
				{@render trigger({ props })}
			{/snippet}
		</Dialog.Trigger>
	{/if}
	<Dialog.Portal>
		<!--
			One node at the end of <body> for the backdrop and panel: the inert pass skips it, and
			its tokens reach both, which Bits UI renders without this component's scoped class.
		-->
		<div bind:this={portalNode} class="modal-dialog contents">
			<Dialog.Overlay class={overlayClass} />
			<Dialog.Content
				bind:ref={panel}
				tabindex={-1}
				class={[panelClass, sizeClass[size]]}
				{onOpenAutoFocus}
				onEscapeKeydown={escapeKeydown}
				onInteractOutside={interactOutside}
			>
				{#snippet child({ props })}
					<!-- Bits UI keeps a removed description's id; only point at one that is there. -->
					<div
						{...props}
						aria-describedby={description ? (props['aria-describedby'] as string) : undefined}
					>
						<header
							class={[
								'flex shrink-0 items-start gap-4 border-b px-6 pt-6 pb-4 transition-colors duration-150 ease-[cubic-bezier(.2,0,0,1)]',
								scrolledPast ? 'border-[var(--_hairline)]' : 'border-transparent'
							]}
						>
							<div class="min-w-0 flex-1">
								<Dialog.Title>
									{#snippet child({ props })}
										<h2
											{...props}
											class="modal-dialog__tracked text-2xl leading-7 font-semibold tracking-[-0.02em] text-balance break-words text-[var(--_ink)]"
										>
											{title}
										</h2>
									{/snippet}
								</Dialog.Title>
								{#if description}
									<Dialog.Description>
										{#snippet child({ props })}
											<p
												{...props}
												class="mt-3 max-w-[30em] text-sm leading-[1.375rem] text-pretty break-words text-[var(--_muted)]"
											>
												{description}
											</p>
										{/snippet}
									</Dialog.Description>
								{/if}
							</div>
							<!-- One title line tall, so the button centres on the first line however the title wraps. -->
							<div class="-me-3 flex h-7 shrink-0 items-center pointer-fine:-me-2">
								<button
									type="button"
									class={closeClass}
									aria-label={closeLabel}
									aria-disabled={dismissible ? undefined : 'true'}
									onclick={closeFromButton}
								>
									<svg class="size-5" viewBox="0 0 20 20" fill="none" aria-hidden="true">
										<path
											d="M5 5l10 10M15 5L5 15"
											stroke="currentColor"
											stroke-width="1.5"
											stroke-linecap="round"
										/>
									</svg>
								</button>
							</div>
						</header>

						<!-- Focusable only while it scrolls, so its content can be scrolled from the keyboard. -->
						<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
						<div
							bind:this={body}
							class="min-h-0 flex-1 [scrollbar-color:var(--_scrollbar)_transparent] overflow-y-auto overscroll-contain px-6 pb-6 break-words focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--_accent)]"
							tabindex={overflowing ? 0 : undefined}
							onscroll={measure}
						>
							<div bind:this={bodyContent}>
								{@render children?.(context)}
							</div>
						</div>

						{#if footer}
							<footer
								class={[
									'flex shrink-0 flex-col-reverse gap-3 border-t border-[var(--_hairline)] bg-[var(--_raised)] px-6 py-4 max-sm:*:w-full sm:flex-row sm:items-center sm:justify-end',
									size === 'full' && 'max-sm:pb-[max(1rem,env(safe-area-inset-bottom))]'
								]}
							>
								{@render footer(context)}
							</footer>
						{/if}
					</div>
				{/snippet}
			</Dialog.Content>
		</div>
	</Dialog.Portal>
</Dialog.Root>

<style>
	/*
	 * Public tokens. The dialog renders at the end of <body>, so set --modal-dialog-* on :root
	 * (or on .modal-dialog globally) rather than on a wrapper around the component.
	 */
	.modal-dialog {
		--_accent: var(--modal-dialog-accent, #fafafa);
		--_on-accent: var(--modal-dialog-on-accent, #18181b);
		--_ink: var(--modal-dialog-ink, #fafafa);
		--_muted: var(--modal-dialog-muted, #a1a1aa);
		--_hairline: var(--modal-dialog-hairline, #ffffff1a);
		--_surface: var(--modal-dialog-surface, #18181b);
		--_raised: var(--modal-dialog-raised, #09090b);
		--_backdrop: var(--modal-dialog-backdrop, #00000099);
		/* Native controls in the body (selects, date pickers) follow it; set dark with a dark surface. */
		--_scheme: var(--modal-dialog-scheme, light);
		/* Hover and press fills are the ink at low strength, so they follow any retone. */
		--_fill: color-mix(in srgb, var(--_ink) 6%, transparent);
		--_fill-pressed: color-mix(in srgb, var(--_ink) 10%, transparent);
		/* The body's scrollbar is drawn from the ink too, so a dark retone never shows a white track. */
		--_scrollbar: color-mix(in srgb, var(--_ink) 24%, transparent);
	}

	/* Arabic and Hebrew are never letter-spaced; tracked text resets under right-to-left. */
	.modal-dialog__tracked:dir(rtl) {
		letter-spacing: 0;
	}
</style>

Open it with bind:open or the trigger snippet, put the task in children and the actions in footer. Both snippets receive close. It does not submit forms, validate fields or talk to a server: wire your own form and set dismissible to false while a request is pending. Not for urgent interruptions or confirmations that need role=alertdialog.

Suggested location
src/lib/components/modal-dialog-01
Required props
opentitle

Limitations

  • Install bits-ui (npm install bits-ui@^2.19.3) before using the component; its open and close transitions need 2.19 or later.
  • The dialog renders at the end of <body>, outside your markup. Set --modal-dialog-* tokens on :root (or globally on .modal-dialog), and set dir and lang on <html> for right-to-left pages; a wrapper's direction and variables do not reach it.
  • It needs JavaScript: nothing is rendered on the server while it is open, and the trigger does nothing before hydration.
  • The footer lays out whatever buttons you give it (stacked full width below 640 px, primary on top when it comes last); it does not style them.
  • Nested dialogs work, but each one locks scroll and marks the page inert on its own; closing the inner one hands both back to the outer.

Example

Svelte
<script lang="ts">
	import ModalDialog from '$lib/components/modal-dialog-01/ModalDialog.svelte';

	let open = $state(false);
	let sending = $state(false);

	async function invite(event: SubmitEvent) {
		event.preventDefault();
		sending = true;
		// await your API here
		sending = false;
		open = false;
	}
</script>

<ModalDialog
	bind:open
	title="Invite people to Roadmap 2027"
	description="Members can edit every card. Guests are free and can only comment."
	dismissible={!sending}
>
	{#snippet trigger({ props })}
		<button {...props} class="rounded-lg bg-zinc-900 px-3.5 py-2 text-sm font-medium text-white">Invite</button>
	{/snippet}
	{#snippet children()}
		<form id="invite" onsubmit={invite} class="flex flex-col gap-2">
			<label for="invite-emails" class="text-sm font-medium">Email addresses</label>
			<input id="invite-emails" type="email" multiple required class="rounded-lg border border-zinc-300 px-3 py-2 text-sm" />
		</form>
	{/snippet}
	{#snippet footer({ close })}
		<button type="button" onclick={close} disabled={sending} class="rounded-lg px-3.5 py-2 text-sm font-medium ring-1 ring-zinc-900/15 ring-inset">Cancel</button>
		<button type="submit" form="invite" class="rounded-lg bg-zinc-900 px-3.5 py-2 text-sm font-medium text-white">{sending ? 'Sending' : 'Send invites'}</button>
	{/snippet}
</ModalDialog>

Opening it#

Either give it a trigger snippet and spread the props it receives on your button, or keep the button yourself and drive bind:open. The trigger's props carry aria-haspopup, aria-expanded, aria-controls and the click handler, and focus goes back to whichever element opened the dialog when it closes.

Svelte
<ModalDialog bind:open title="Rename board" size="sm">
	{#snippet trigger({ props })}
		<button {...props}>Rename</button>
	{/snippet}
	...
</ModalDialog>

The footer sits outside the body, so a form in children reaches its submit button through the form attribute:

Svelte
{#snippet children()}
	<form id="rename" onsubmit={rename}>...</form>
{/snippet}
{#snippet footer({ close })}
	<button type="button" onclick={close}>Cancel</button>
	<button type="submit" form="rename">Rename</button>
{/snippet}

Put the secondary action first and the primary last. From 640 px they sit at the end of the footer band; below it they stack full width with the primary on top. A note that belongs in the footer, such as a count, can take sm:me-auto to sit at the start.

While a request is pending#

The dialog does not submit anything. While your request is in flight, set dismissible={false}: the close button reads as disabled, and Escape and the backdrop do nothing. The close your snippets receive still works, so close it from your success handler. Hold your submit button's width while its label changes.

To ask before discarding changes, pass onEscapeKeydown and call event.preventDefault() when there is something to lose.

Tokens and direction#

The dialog renders at the end of <body>, not where you put the component, so a wrapper's CSS variables and dir never reach it. Set --modal-dialog-* on :root (or on .modal-dialog in a global stylesheet) and dir/lang on <html>.

Inside the dialog, var(--_accent), var(--_on-accent), var(--_ink), var(--_muted), var(--_hairline), var(--_surface) and var(--_raised) resolve to the current tokens, so your footer buttons and fields can follow a retone without repeating colours.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
openbooleanYesfalseWhether the dialog is open. Bindable with bind:open; the trigger, close button, Escape and backdrop all update it.
titlestringYesNoneDialog heading, rendered as an h2; it names the dialog through aria-labelledby.
descriptionstringNoNoneOne or two sentences under the title; it describes the dialog through aria-describedby. Omitted, nothing renders there.
childrenSnippet<[{ close: () => void }]>NoNoneThe task: a form, details or settings. Scrolls between the fixed header and footer when it is long.
triggerSnippet<[{ props: Record<string, unknown> }]>NoNoneA button that opens the dialog. Spread props on it for aria-haspopup, aria-expanded, aria-controls and the click handler. Omit it to control open yourself.
size'sm' | 'md' | 'lg' | 'full'No'md'Panel width from 640 px: 384, 512, 672 or 1024 px. full is also the viewport height less a margin, and a full-screen sheet below 640 px.
closeOnOutsideClickbooleanNotrueWhether a press on the backdrop closes the dialog.
dismissiblebooleanNotrueWhether the visitor can dismiss the dialog with the close button, Escape or the backdrop. Set it false while a submission is pending; close from the snippets still works.
initialFocusstringNoNoneCSS selector, inside the dialog, of the element to focus on open. Without it the first control in the body gets focus, or the panel itself when the body has none.
closeLabelstringNo'Close'Accessible name of the close button.
onOpenChange(open: boolean) => voidNoNoneCalled with the new state whenever the dialog opens or closes, including from close() in a snippet. Not called when open is changed from outside.
onEscapeKeydown(event: KeyboardEvent) => voidNoNoneCalled when Escape is pressed while the dialog is open; call event.preventDefault() to keep it open, for example to confirm discarding changes.

Customization#

On this page

Content goes in the children and footer snippets; retone through nine --modal-dialog-* variables set on :root. The accent is the close button's focus ring and is exposed to your buttons as --_accent inside the dialog; the rest is text, hairlines, the panel, the footer band, the backdrop and the colour scheme native controls inside it use.

  • Content: put a form, a details list or settings rows in children, and the actions in footer, secondary first and primary last. Give a form an id and point the submit button at it with form="..." so it can sit in the footer.
  • Size: sm for one or two fields, md (the default) for a short form, lg for details in two columns, full for a workspace task such as an import; full is a full-screen sheet on phones.
  • Pending: set dismissible={false} while a request is in flight and hold your submit button's width; set it back and call close() when it succeeds.
  • Colours: set tokens on :root because the dialog renders at the end of <body>. --modal-dialog-surface is the panel, --modal-dialog-raised the footer band, --modal-dialog-backdrop the dimmed page, --modal-dialog-hairline the panel edge and dividers.
  • Your buttons: inside the dialog, var(--_accent), var(--_on-accent), var(--_ink), var(--_muted) and var(--_hairline) resolve to the tokens, so footer buttons can follow a retone without repeating the colours.
  • Dark app retone: --modal-dialog-surface: #18181b; --modal-dialog-raised: #09090b; --modal-dialog-ink: #fafafa; --modal-dialog-muted: #a1a1aa; --modal-dialog-hairline: rgb(255 255 255 / 0.1); --modal-dialog-accent: #fafafa; --modal-dialog-on-accent: #18181b; --modal-dialog-backdrop: rgb(0 0 0 / 0.6).
  • Native controls: set --modal-dialog-scheme: dark with a dark surface, so selects, date pickers and scrollbars in the body are drawn dark too. It is a color-scheme value (light by default), not a colour.
  • Focus: pass initialFocus="#field-id" to start somewhere other than the first control, for example the primary field of a long form.
  • Motion: opening scales from 96% over 220 ms and closing fades over 150 ms; edit the data-[starting-style] and data-[ending-style] classes to change them. Under reduced motion both only fade.

Public CSS variables

VariableToken
--modal-dialog-accentaccent
--modal-dialog-on-accentonAccent
--modal-dialog-inkink
--modal-dialog-mutedmuted
--modal-dialog-hairlinehairline
--modal-dialog-surfacesurface
--modal-dialog-raisedraised
--modal-dialog-backdropbackdrop
--modal-dialog-schemescheme

Dependencies and services#

On this page
PackageRangeResolved at build timePurpose
bits-ui^2.19.32.19.3Headless Dialog primitive: focus trap and restore, Escape and outside-press handling, portal, scroll lock with scrollbar-gap compensation, and presence for exit transitions.

Install with (shown for reference, run it yourself)

npm install bits-ui@^2.19.3

Accessibility#

On this page
  • Follows the WAI-ARIA APG modal dialog pattern through Bits UI: role=dialog, aria-modal=true, aria-labelledby on the title and aria-describedby on the description.
  • Tab and Shift+Tab loop inside the dialog; Escape closes it unless dismissible is false or onEscapeKeydown prevents it; focus returns to the element that opened it.
  • Everything else in <body> is marked inert while the dialog is open, so screen readers and pointer users cannot reach the page behind it; page scroll is locked with the scrollbar's gap held.
  • On open, focus goes to initialFocus, else the first control in the body, else the panel, so the title is never scrolled out of view by autofocus.
  • The close button is a real button with an accessible name (closeLabel), 44 px for touch and 36 px under a fine pointer, with a focus ring in the accent. While dismissible is false it stays focusable with aria-disabled.
  • A body long enough to scroll joins the tab order so it can be scrolled from the keyboard.
  • Consumer responsibilities: label every field in children, announce a submission's result, keep one primary action in the footer, and give a disabled pending button a reason people can read.

Known limitations

  • Direction and tokens come from <html> and :root, not from the component's parent, because the dialog is portalled to <body>.
  • iOS Safari can still rubber-band the page under the backdrop in some versions; the body itself uses overscroll-behavior: contain.

Release details#

On this page
Integration
  • Local interaction
  • Requires client-side JavaScript to be interactive
  • Server-side rendering supported
License

MIT. Default license approval is pending; see the license status before adopting the source.

Version history
  • 1.0.0 (Published) Current release · 30 September 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.