AniUI Academy

Build a modal dialog

mediumAccessibility+200 XP

Build a modal that opens from a button.

It should close three ways: an explicit close button, pressing Escape, and clicking the backdrop behind it — but clicking inside the modal's own content must never close it.

Examples

// Click <button data-testid="open-modal">
// → <div role="dialog" aria-modal="true"> appears,
//    wrapped in a backdrop.

// Press Escape      → dialog closes.
// Click backdrop    → dialog closes.
// Click dialog body → nothing happens.
// Click close btn   → dialog closes.

Four open/close paths; the body-click is the one that must NOT bubble into a close.

useEffect(() => {
  if (!open) return;
  function onKey(e) {
    if (e.key === "Escape") setOpen(false);
  }
  window.addEventListener("keydown", onKey);
  return () => window.removeEventListener("keydown", onKey);
}, [open]);

// On the dialog body:
<div role="dialog" onClick={(e) => e.stopPropagation()}>

The Escape listener is scoped to the open state; `stopPropagation` on the dialog guards against the backdrop handler.

What it has to do

  • A button `data-testid="open-modal"` opens the modal.
  • While open, an element with `role="dialog"` is present in the DOM; while closed, it is not present at all (unmounted, not just hidden).
  • A button `data-testid="close-modal"` inside the dialog closes it.
  • Pressing Escape while the modal is open closes it.
  • A `data-testid="modal-backdrop"` element behind the dialog closes it when clicked directly — but a click on the dialog content itself must not propagate into a close.

Notes

  • Trap focus inside the dialog while it's open (first/last focusable loop), and restore focus to the trigger button on close — screen reader + keyboard users rely on it.
  • Lock body scroll with `document.body.style.overflow = "hidden"` while open; otherwise iOS and desktop users can scroll the page behind a mounted modal.
  • `role="dialog"` + `aria-modal="true"` is the minimum. Give the dialog an accessible name with `aria-labelledby` pointing to the heading inside it.
  • A native `<dialog>` element now handles most of this for free (`showModal`, focus trap, Escape, backdrop pseudo-element) — reach for it before building bespoke in new code.
Asked atAirbnbStripeShopifyMicrosoft

Stuck? The react course covers the ideas this problem is built on.

import { useState } from "react";

export default function App() {
  const [open, setOpen] = useState(false);

  // TODO: pressing Escape while the modal is open should also close it.
  // (Closing via the button and the backdrop already works below.)

  return (
    <div style={{ fontFamily: "sans-serif", padding: 16 }}>
      <button data-testid="open-modal" onClick={() => setOpen(true)}>
        Open modal
      </button>

      {open ? (
        <div
          data-testid="modal-backdrop"
          onClick={() => setOpen(false)}
          style={{
            position: "fixed",
            inset: 0,
            background: "rgba(0,0,0,0.4)",
            display: "grid",
            placeItems: "center",
          }}
        >
          <div
            role="dialog"
            aria-modal="true"
            onClick={(e) => e.stopPropagation()}
            style={{ background: "white", padding: 24, borderRadius: 8, minWidth: 240 }}
          >
            <p>Modal content</p>
            <button data-testid="close-modal" onClick={() => setOpen(false)}>
              Close
            </button>
          </div>
        </div>
      ) : null}
    </div>
  );
}

5 tests · +200 XP

Discussion

Sign in to post
Sign in to join the conversation.Sign in

No comments yet. Be the first to say something.