Skip to the solution
MediumFrontend15 min read

Build an Accessible Modal with Focus Trap & Scroll Lock

699 views

The problem

Question presented to candidate: "You already have a basic modal that renders through a portal and closes on Escape and overlay click. Our accessibility audit failed it: keyboard users can Tab out of it, screen readers still read the page behind it, the page scrolls under it, and a confirmation opened from inside it breaks everything. Make it pass."

Requirements:

  • Focus trap: Tab and Shift+Tab cycle inside the top-most dialog, skipping disabled and hidden controls; focus that somehow lands outside is pulled back in.
  • Initial focus on a marked element (or the first tabbable, or the dialog); on close, focus returns to whatever was focused before it opened.
  • role="dialog" with aria-modal="true" and a label; everything outside the dialog is made inert while it is open.
  • Body scroll lock while any dialog is open, with scrollbar-width compensation so the page does not shift, and a reference count so stacked dialogs unlock only when the last one closes.
  • Stacked dialogs: a confirmation opened from inside the editor; Escape and the trap apply only to the top-most one; closing it returns focus inside the editor.

What a strong answer should cover:

  • The order of operations on open (remember focus, inert, lock, move focus) and its exact reverse on close, including why inert must be removed before focus is restored.
  • A tabbable query that excludes disabled, tabindex="-1", inert and invisible elements, computed on each Tab press.
  • A layer stack so only the top dialog reacts to keys, and why React portal events bubbling through the component tree make this necessary.
  • Scroll lock with overflow: hidden on body, padding compensation from innerWidth - clientWidth, and a shared counter.
  • What the native dialog element with showModal() gives you for free, and what it does not (scroll lock).

Clarifying questions expected:

  • "Can dialogs stack, and should Escape close all of them or only the top one?"
  • "Which browsers do we support? (inert needs a 2023-era browser.)"
  • "Is iOS Safari in scope? Its scroll locking has extra quirks."

Code / implementation expected: Yes. A framework-free helper for trap, inert and scroll lock, a modal component that uses it, and a demo with a nested dialog.

  • React
  • Modal
  • Focus Trap
  • Accessibility
  • Portal

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

15 min read Β· 10 sections

React solution

Every file of the working solution. Open it in the playground to run and change it.

ReactComplete solution
import { useState } from "react";
import AccessibleModal from "./src/components/AccessibleModal";

const PARAGRAPHS = Array.from(
  { length: 30 },
  (_, i) =>
    "Background paragraph " +
    (i + 1) +
    ". The page behind the modal is long enough to scroll.",
);

export default function App() {
  const [editing, setEditing] = useState(false);
  const [confirming, setConfirming] = useState(false);
  const [name, setName] = useState("Ada Lovelace");
  const [draft, setDraft] = useState(name);
  const [status, setStatus] = useState("No changes yet");

  function openEditor() {
    setDraft(name);
    setEditing(true);
  }
  function save(e) {
    e.preventDefault();
    setName(draft);
    setStatus("Saved: " + draft);
    setEditing(false);
  }
  function discard() {
    setConfirming(false);
    setEditing(false);
    setStatus("Changes discarded");
  }

  return (
    <div className="app">
      <header className="bar">
        <strong>Profile: {name}</strong>
        <button
          type="button"
          className="btn"
          data-testid="open"
          onClick={openEditor}
        >
          Edit profile
        </button>
      </header>
      <main className="page">
        <p data-testid="status" aria-live="polite">
          {status}
        </p>
        {PARAGRAPHS.map((p) => (
          <p key={p}>{p}</p>
        ))}
      </main>

      <AccessibleModal
        open={editing}
        title="Edit profile"
        testId="editor"
        onClose={() => setEditing(false)}
      >
        <form onSubmit={save}>
          <label>
            Name{" "}
            <input
              data-autofocus
              data-testid="name"
              value={draft}
              onChange={(e) => setDraft(e.target.value)}
            />
          </label>
          <label>
            Email{" "}
            <input
              data-testid="email"
              type="email"
              defaultValue="ada@example.com"
            />
          </label>
          <div className="actions">
            <button type="button" className="btn" disabled>
              Delete account
            </button>
            <button
              type="button"
              className="btn"
              data-testid="discard"
              onClick={() => setConfirming(true)}
            >
              Discard…
            </button>
            <button type="submit" className="btn primary" data-testid="save">
              Save
            </button>
          </div>
        </form>
        <AccessibleModal
          open={confirming}
          title="Discard your changes?"
          testId="confirm"
          onClose={() => setConfirming(false)}
        >
          <div className="actions">
            <button
              type="button"
              className="btn"
              data-autofocus
              data-testid="keep"
              onClick={() => setConfirming(false)}
            >
              Keep editing
            </button>
            <button
              type="button"
              className="btn danger"
              data-testid="really-discard"
              onClick={discard}
            >
              Discard
            </button>
          </div>
        </AccessibleModal>
      </AccessibleModal>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 48 of 107 in Machine coding

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

Build a Speed Dial Menu

Continue