A modal that respects focus
Kathryn Gonzalez led design infrastructure at DoorDash, and in November 2017 she wrote How to Build a Modal: “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.” Three stories about one modal taught her team about z-index management, accessibility obligations, and a gap in testing.
Stacking contexts, not z-index#
First, a support ticket: event sign-ups collapsed on desktop only, days after a coworker moved an RSVP flow into the new modal. On mobile the guest-count dropdown was a native select; on desktop the custom dropdown rendered behind the modal.
She quotes MDN on stacking contexts, “a three-dimensional conceptualization of HTML elements along an imaginary z-index relative to the user.” A child in one context cannot out-stack a child in another, whatever z-index you give it.
Her takeaway: create new stacking contexts for application content and for modal content, so z-index changes in either stay contained. 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.
Focus goes in and comes back#
Six months later an ADA audit found keyboard accessibility “pretty bad,” chiefly because opening a modal did not move focus into it. She condenses MDN’s dialog guidance: at least one focusable control; focus moves to the default control on open; focus returns to where it was on dismiss; and the tab order wraps inside the dialog. role=“dialog” gives you none of this; you implement it or a library does.
Hence a focus trap. 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; re-implementing this per feature is a path to madness and missed edge cases.
var containerOne = document.getElementById("demo-one")var focusTrapOne = createFocusTrap("#demo-one", { onDeactivate: function () { containerOne.className = "trap" },})document.getElementById("activate-one").addEventListener("click", function () { focusTrapOne.activate() containerOne.className = "trap is-active"})Open the dialog in the figure below, then press Tab and Shift+Tab. Focus cycles between its four controls and never reaches the page behind it. Turn the Trap focus switch off, open the dialog again, and keep tabbing. This time focus never moves inside on open and walks straight past the dialog into the page, the same bug the ADA audit found.
Scroll lock and the real viewport#
Third, two tickets about scrolling: the page behind the modal kept scrolling, and on mobile a confirm button fixed to the bottom hid behind the browser’s controls. Viewport-height units are not the visible area on mobile; she cites a survey of URL bar behaviour and a Safari contributor explaining that iOS uses the larger size because relayout during scroll at 60 FPS is not practical. Her fix was a calc for the pixels Safari hides.
And scroll lock 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. The team had tested mobile only in Chrome’s responsive mode. “Always test with real devices.”
Four takeaways and the motion#
Her takeaways: consider accessibility from the start and test with screen readers; know the content the modal will hold; “Design systems, for all they are in technology, are also about processes”; and make the component a tool to distribute best practices, so scroll locking, focus, and mobile behaviour are solved once.
She does not discuss how the modal moves, and Emil Kowalski fills that in. His duration table puts modals and drawers at 200 to 300 ms, and his skill file exempts modals from origin-aware scaling: “modals should keep transform-origin: center because they are not anchored to a specific trigger.” Both are in Practical tips.
.modal { transform-origin: center; transition: transform 200ms;}body.modal-open { position: fixed;}