← Back to UI Patterns

UI Patterns

Modal Dialogs Must Control Focus and Provide Reliable Close Paths

Modals must trap focus when open, restore it on close, and support all standard dismiss actions without losing scroll position.

Rule

Every modal dialog must manage focus and expose multiple reliable close mechanisms.

Why

Modals that leak focus or have no keyboard close path are inaccessible and frustrating for all users.

Must

  • Move focus to the modal on open (first focusable element or dialog title).
  • Trap focus inside the modal while it is open.
  • Restore focus to the trigger element on close.
  • Close on Esc key.
  • Close on explicit close button click.
  • Close on backdrop click for non-destructive dialogs.
  • Set aria-modal="true" and role="dialog" with an accessible label.

Should

  • Prevent body scroll while open.
  • Animate open and close with reduced-motion support.

Anti-patterns

  • Focus left on a background element while modal is open.
  • Closing a destructive confirmation dialog on backdrop click.
  • Using position: fixed without disabling body scroll on mobile.

Test Cases

  • Tab key cycles only within the modal.
  • Esc closes and returns focus to trigger.
  • Screen reader announces dialog role and title on open.

Telemetry

  • modal_opened
  • modal_closed_by (esc, backdrop, button)
  • modal_focus_leak_errors

Code Examples

Svelte

<script lang="ts">
	import { onDestroy, tick } from 'svelte';

	let open = false;
	let trigger: HTMLButtonElement | null = null;
	let closeButton: HTMLButtonElement | null = null;
	let previousBodyOverflow = '';
	let isBodyScrollLocked = false;

	function lockBodyScroll() {
		if (typeof document === 'undefined' || isBodyScrollLocked) return;

		previousBodyOverflow = document.body.style.overflow;
		document.body.style.overflow = 'hidden';
		isBodyScrollLocked = true;
	}

	function unlockBodyScroll() {
		if (typeof document === 'undefined' || !isBodyScrollLocked) return;

		document.body.style.overflow = previousBodyOverflow;
		previousBodyOverflow = '';
		isBodyScrollLocked = false;
	}

	async function openDialog() {
		open = true;
		lockBodyScroll();
		await tick();
		closeButton?.focus();
	}

	function closeDialog() {
		open = false;
		unlockBodyScroll();
		trigger?.focus();
	}

	function onBackdropClick(event: MouseEvent) {
		if (event.target === event.currentTarget) {
			closeDialog();
		}
	}

	function onKeydown(event: KeyboardEvent) {
		if (!open) return;
		if (event.key === 'Escape') closeDialog();
	}

	onDestroy(() => {
		unlockBodyScroll();
	});
</script>

<svelte:window on:keydown={onKeydown} />

<button bind:this={trigger} type="button" on:click={openDialog}>Open modal</button>

{#if open}
	<div class="backdrop" on:click={onBackdropClick}>
		<section
			role="dialog"
			aria-modal="true"
			aria-labelledby="dialog-title"
			on:click|stopPropagation
		>
			<h2 id="dialog-title">Delete project</h2>
			<p>Return focus to the trigger when the dialog closes.</p>
			<button bind:this={closeButton} type="button" on:click={closeDialog}>Close</button>
		</section>
	</div>
{/if}

<style>
	.backdrop {
		position: fixed;
		inset: 0;
		background: rgb(15 18 28 / 0.48);
		backdrop-filter: blur(10px);
	}
</style>

Trap focus inside the dialog, prevent background scrolling, blur the page behind the modal, close when the user clicks the backdrop, support Escape, and always restore focus to the trigger when the dialog closes.