Frontend Handbook
08Built

Accessible Modal

A dialog with focus trap, Escape to close, and focus restore.

How it works

A modal dialog that keyboard and screen reader users can rely on: focus moves in and can't wander behind it, the page underneath can't be scrolled, clicked or read, Escape and the backdrop close it unless that would lose work, and focus comes back to where you were. It is the playground's shared dialog: any practice can open one or await confirm().

  • Make the rest inert: everything behind the top dialog gets the inert attribute; Tab still wraps by hand inside the dialog.
  • Choose where focus starts: the safe button for confirmations, the first field for forms, the title for long documents.
  • Give focus back: to the opener, or to a sensible fallback when the opener is gone, never to the page body.
  • Ask before losing work: the owner decides on every Escape or backdrop click, so unsaved edits get a “Discard changes?” first.
Read more

Where you'd use it

  • Confirmations for destructive or irreversible actions (delete, cancel a subscription, sign out everywhere).
  • Short focused tasks that shouldn't lose the page behind them: edit a record, invite a teammate, rename a file.
  • Steps that block until answered: unsaved changes, session expiring, terms to accept.
  • Not for: menus, tooltips or anything the user should be able to ignore (those are non-modal).

How we built it

A small engine with no React keeps a stack of open dialogs. Each dialog is role="dialog" (or alertdialog for confirmations) with aria-modal, named by its title and described by its description, rendered through a portal at the end of the page. When one opens, the engine walks the page from <body> and marks every branch that doesn't contain the top dialog inert, so it can't be focused, clicked or reached by a screen reader. The toaster is marked data-modal-keep and skipped, so toasts raised by a dialog are still announced and clickable.

Inert doesn't wrap Tab (from the last control focus would go to the browser's toolbar), so a keydown listener wraps Tab and Shift+Tab inside the top dialog, and a focusin listener brings back focus that a script moves out. Page scroll is locked with overflow: hidden on <html>; when a classic scrollbar is present, scrollbar-gutter: stable keeps its space so nothing shifts.

The <Modal> component is controlled: Escape, the backdrop and × only ask its owner to close (onRequestClose(reason)). Confirmations ignore the backdrop, and the profile form opens “Discard changes?” on top of itself when it has edits. A backdrop click counts only when the press both started and ended on the backdrop. Focus returns one frame after closing starts, so a caller can remove the opener first (the deleted row); when several dialogs close together, focus goes to the lowest one's opener. Opening uses CSS @starting-style; closing waits for the transition before unmounting, with no animation under reduced motion. On phones the same dialog becomes a bottom sheet sized from visualViewport, so it stays above the on-screen keyboard.

Best practices

  • Do: Make everything outside the dialog inert, and still wrap Tab yourself.

    Avoid: Relying on aria-modal alone: some screen readers still browse the page behind, and the mouse can still click it.

  • Do: Start on the least destructive action in a confirmation.

    Avoid: Focusing “Delete”: a habitual Enter destroys data.

  • Do: Return focus to the opener, or to a logical neighbour when it was removed.

    Avoid: Letting focus fall to the page body, which sends keyboard users back to the top of the page.

  • Do: Let the owner veto dismissal (unsaved changes, confirmations).

    Avoid: Closing on every Escape or backdrop click, or blocking Escape entirely.

  • Do: Keep the header and actions visible and scroll only the body; reserve the scrollbar gutter.

    Avoid: Dialogs taller than the screen, and a page that jumps sideways when the scrollbar disappears.

Try it with the demo controls

Delete (projects)
Focus starts on Cancel; clicking the backdrop is logged as ignored. Delete: the row goes and focus lands on the next row, or “New project” after the last one.
Edit profile
Change a field, then press Escape, click the backdrop or Cancel: “Discard changes?” opens on top. Keep editing returns to your field; Discard closes both and focus is back on Edit profile. Save with an empty name: focus jumps to the error.
Open settings
Settings → Advanced… → Reset all settings…: three levels. Escape closes one at a time, each time focusing the button that opened it.
Read terms
Focus is on the title; Tab reaches the scrollable text, where arrow keys scroll it. Scrolling to the end never scrolls the page.
Native confirm
The browser's own modal: inert page, Escape and focus return for free, but the page still scrolls behind it and toasts are blocked too.
Backdrop click closes
Off: the backdrop does nothing anywhere (logged as ignored). Confirmations ignore it either way.
Animations
Off (or with reduced motion in your OS): dialogs appear and disappear instantly.
Layout
Bottom sheet: full width, attached to the bottom, actions stacked and taller; Auto switches when the window is narrower than 640 px.
Event log
Every open, close (with its reason) and ignored dismissal, with where focus went.

Trade-offs and limits

  • Native <dialog>.showModal() now gives the top layer, inert, Escape and focus return for free; a hand-built dialog is still useful for scroll locking, exit animations, keeping toasts reachable and dismissal rules, but it is more code to get right.
  • Inert is applied to elements that exist when the dialog opens; content added to the page later (another library's portal) isn't covered until the next dialog opens or closes.
  • Toasts stay reachable by pointer and F8 while a dialog is open, which is a deliberate hole in the “nothing behind is reachable” rule.
  • The phone sheet has no swipe-to-dismiss; it closes with its buttons, Escape or the backdrop.
  • Dialogs aren't tied to the URL: Back leaves the page and closes them rather than closing just the dialog.

Demo controls

Confirm before deleting

An alert dialog from confirm(): focus starts on Cancel, the backdrop is ignored, and focus moves to the next row after a delete.

  • Website redesign
  • Mobile app
  • Quarterly report
  • Onboarding flow

Edit without losing work

Closing with unsaved edits asks first, in a dialog on top of the form.

Name
Alex Morgan
Email
alex@example.com

Stacked dialogs

Each level opens from the one below; Escape closes one level at a time.

Long content

A dialog taller than the screen: the body scrolls, the title and actions stay put.

Native <dialog> comparison

The browser makes the page inert (toasts too), handles Escape and returns focus. Page scroll isn't locked, and the backdrop doesn't close it.

Delete “Website redesign”? (native)

Built on the native dialog element. Nothing is actually deleted.

Open a dialog: each open, close and ignored dismissal shows up here with where focus went.