Build · Components
A modal that respects focus

A modal that respects focus

Kathryn Gonzalez led design infrastructure at DoorDash, and in November 2017 she wrote How to Build a Modal, subtitled “what it really takes to build a core part of your design system.” Her opening claim is modest and her evidence is not: “While building a modal can seem simple from the start, there are a lot of cases and issues to discover once you release and iterate on it.” The post is three stories, lightly adjusted from reality, about a modal that shipped and then taught its team three things: that z-indexes need consistent management, that a modal carries many accessibility obligations, and that their testing had a gap.

The first story is a support ticket. Event sign-ups collapsed on desktop web only, days after a coworker moved an existing RSVP flow into the new modal. The guest-count dropdown had not changed; on mobile it used the native select and worked, while on desktop the custom dropdown rendered behind the modal. The lesson she draws is about stacking contexts, which she defines by quoting MDN: “a three-dimensional conceptualization of HTML elements along an imaginary z-index relative to the user.” A child inside one context cannot out-stack a child in another, whatever z-index you give it; only the elements that created the contexts can be reordered. Her takeaway is to create new stacking contexts for application content and for modal content, so that z-index changes in either cannot put things above or below where they belong. At DoorDash a LayerManager component, built on react-gateway, renders modal content into its own stacking context so the modal is guaranteed to sit above the app.

The second story arrives six months later from user research: an ADA guidelines audit found keyboard accessibility “pretty bad,” and the biggest culprit was that opening a modal did not move focus into it. Keyboard users tabbed through the page content under the overlay before reaching the dialog. Her answer is that the WAI-ARIA documentation and examples “are your friend,” and she condenses the requirements by quoting MDN’s dialog guidance. A dialog always needs at least one focusable control. When it appears, focus moves to the default control inside it, an OK button for a message or the first field for a form. When it is dismissed, focus returns to where it was before, otherwise focus drops to the start of the page. And the tab order wraps, so the first focusable element follows the last: the tab order is contained by the dialog.

That last point is why she reaches for a library. A such as focus-trap “will manage saving the last focused element, finding and focusing on the first focus-able element of the modal, keeping focus within the modal, and restoring focus to the last focused element once the modal is unmounted.” DoorDash uses focus-trap-react inside its modal so that, whatever a product team puts in it, keyboard users get the right behaviour. Re-implementing this per feature, she writes, is a path to madness and to missed edge cases; a standardized dialog in the design system applies it once.

The third story is a pair of tickets about scrolling. Scrolling to the bottom of a modal kept scrolling the page behind it, and on mobile a confirm button fixed to the bottom of the viewport sat hidden behind the browser’s navigation controls, so people scrolled and moved the page rather than the modal. She found three real problems. The modal assumed viewport-height units meant the visible area, and on mobile they do not; she cites a survey of URL bar behaviour and a Safari contributor explaining that iOS deliberately uses the larger view size because relayout during scroll at 60 FPS is not practical. Her fix was a calc that accounts for the pixels Safari’s controls hide. Next, is not overflow: hidden. “On iOS, there are only two reliable ways of doing this: Setting position: fixed on the document.body, or adding event listeners that prevent the default behavior of touchmove on the body content.” They chose position: fixed because short-circuiting touch events had worse trade-offs. And the team had tested mobile only in Chrome’s responsive mode, never on a device, which is why none of this was caught. “Always test with real devices. Always test your design in the real contexts that they’ll be used.”

Her takeaways are four. Consider modal accessibility from the start, and test it across browsers, desktop and mobile, and with screen readers. Know the content the modal will contain and guard for edge cases, because a layering component will hold layered things. The system is made of processes as much as components: “Design systems, for all they are in technology, are also about processes.” And make the component a tool to distribute best practices, so that scroll locking, focus, and mobile behaviour are solved once and product teams can think at a higher level of abstraction.

Her post does not discuss how the modal moves, beyond calling it animated, and this is where Emil Kowalski’s guidance slots in. His duration table puts modals and drawers at 200 to 300 ms, inside the under-300 ms rule, and his skill file makes modals the exception to origin-aware scaling: popovers should grow from their trigger, but “modals should keep transform-origin: center because they are not anchored to a specific trigger.” Both are covered in Practical tips. Put the two authors together and the modal that respects focus is also the one that respects the clock and the viewport: stacked in its own context, focus trapped and returned, the page behind it locked with position: fixed rather than overflow: hidden, sized for the viewport a real phone actually shows, and entering in a quarter of a second from its own centre.