B / React blog

Building an Accessible React Modal Is Harder Than It Looks

A practical accessibility tutorial about the focus, keyboard, labeling, portal, scroll, and nested-dialog behavior hidden behind a React modal.

Focus
React architecture
Published
Estimated reading time
13 min read
  • React accessibility
  • Dialogs
  • Focus management
  • UI architecture

A modal often begins as a small interface task: render a panel over the page, darken the background, and add a close button. That may look complete with a mouse. For a keyboard or screen-reader user, it can still be a broken navigation system.

An accessible modal must coordinate focus, keyboard input, document semantics, stacking, scrolling, and cleanup. Each concern is manageable on its own. The engineering difficulty comes from making them behave as one system, including when content changes or a second dialog opens.

This tutorial builds an educational React modal and analyzes where the deceptively small implementation becomes fragile. The goal is not to produce another component library. It is to make the accessibility contract visible.

B-01

Start with the behavioral contract

Before writing JSX, describe what must happen from the user's point of view.

  • Activating the trigger opens the dialog and moves focus to a deliberate place inside it.
  • Tab and Shift+Tab remain within the active dialog while it is modal.
  • Escape closes only the topmost dismissible dialog.
  • Closing returns focus to the element that opened the dialog, or to a sensible fallback if that element no longer exists.
  • The dialog has a name, and an optional description, that assistive technology can announce.
  • Background content is visually obscured and unavailable for interaction.
  • The page behind the dialog does not scroll, while long dialog content remains usable.
  • A nested dialog temporarily takes control without corrupting the parent dialog's state or focus history.

That list is the component. The overlay, rounded panel, and animation are only its presentation.

B-02

Give the dialog a real accessible name

ARIA does not create behavior, but it does communicate the behavior that the code actually implements. A custom modal container normally needs `role="dialog"` and `aria-modal="true"`. It also needs an accessible name.

The most reliable pattern is to render a visible title and reference it with `aria-labelledby`. A short supporting sentence can be connected with `aria-describedby`.

const titleId = useId();
const descriptionId = useId();

<div
  aria-describedby={description ? descriptionId : undefined}
  aria-labelledby={titleId}
  aria-modal="true"
  ref={dialogRef}
  role="dialog"
  tabIndex={-1}
>
  <h2 id={titleId}>{title}</h2>
  {description ? <p id={descriptionId}>{description}</p> : null}
  {children}
  <button type="button" onClick={onDismiss}>
    Close dialog
  </button>
</div>

The close button needs its own understandable accessible name. An icon can be visible, but the button cannot be announced merely as “button.”

Do not automatically put the entire dialog body in `aria-describedby`. A short sentence works well. A long document, complex form, or structured set of headings is often clearer when screen-reader users can explore that content normally after focus enters the dialog.

`aria-modal="true"` is a promise, not an implementation. It tells assistive technology that the rest of the page is unavailable. It does not trap focus, lock scrolling, make the background inert, or close the dialog with Escape. The code must make the promise true.

B-03

Focus placement is a product decision

“Focus the first element” sounds reasonable until the first element is a destructive button or an input that opens the software keyboard on a phone.

Choose initial focus from the purpose and shape of the dialog:

  • For a short form, focus the first field only when immediate typing is the expected next action.
  • For a destructive confirmation, focus the least destructive action, usually Cancel.
  • For a long or highly structured dialog, focus a static heading or introductory element with `tabIndex={-1}` so reading starts at the top without skipping content.
  • For a simple choice, focus the most likely safe action.
  • When touch opened the dialog, consider whether focusing an input would cover important context with the virtual keyboard.

The component API therefore needs more than an `open` boolean. It needs a way for the feature using the dialog to nominate the correct initial target.

type ModalProps = {
  initialFocusRef?: RefObject<HTMLElement | null>;
  open: boolean;
  onDismiss: () => void;
  triggerRef?: RefObject<HTMLElement | null>;
};

useLayoutEffect(() => {
  if (!open) return;

  const frame = requestAnimationFrame(() => {
    const target = initialFocusRef?.current ?? dialogRef.current;
    target?.focus();
  });

  return () => cancelAnimationFrame(frame);
}, [initialFocusRef, open]);

This fallback focuses the dialog container, which has `tabIndex={-1}`. It is safer than leaving focus behind the overlay, but it is not a substitute for choosing the right target for a real workflow.

B-04

A focus trap is a live boundary

A modal dialog contains its tab sequence. Pressing Tab on the last tabbable element wraps to the first; Shift+Tab on the first wraps to the last.

A simplified implementation can query the current tabbable elements whenever Tab is pressed. Querying at event time matters because validation errors, loading states, conditional fields, and disabled buttons can change the set after the dialog opens.

const focusableSelector = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled]):not([type="hidden"])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(",");

function getTabbableElements(container: HTMLElement) {
  return Array.from(
    container.querySelectorAll<HTMLElement>(focusableSelector),
  ).filter(
    (element) =>
      !element.hidden &&
      element.getAttribute("aria-hidden") !== "true" &&
      !element.closest("[inert]"),
  );
}

function keepTabInside(event: KeyboardEvent, dialog: HTMLElement) {
  if (event.key !== "Tab") return;

  const tabbable = getTabbableElements(dialog);
  const first = tabbable[0];
  const last = tabbable.at(-1);
  const active = document.activeElement;

  if (!first || !last) {
    event.preventDefault();
    dialog.focus();
    return;
  }

  if (active === dialog) {
    event.preventDefault();
    (event.shiftKey ? last : first).focus();
    return;
  }

  if (event.shiftKey && (active === first || !dialog.contains(active))) {
    event.preventDefault();
    last.focus();
  } else if (
    !event.shiftKey &&
    (active === last || !dialog.contains(active))
  ) {
    event.preventDefault();
    first.focus();
  }
}

This is suitable for learning, not a complete tabbability engine. Real applications must account for visibility, radio groups, details elements, shadow roots, iframes, contenteditable regions, elements moved during an animation, and browser-specific focus behavior. A selector that finds focusable-looking nodes is not necessarily a correct model of the browser's tab order.

The trap must also defend its boundary when focus moves through scripting or pointer interaction, not only through Tab. Background content should be inert so it cannot receive focus or clicks in the first place.

B-05

Escape must belong to the active layer

Escape support is easy with one modal and easy to break with two. A document-level listener attached by every open dialog can close the entire stack from one keypress.

Each dialog needs an identity, and a shared layer manager must know which identity is currently on top.

const modalStack: symbol[] = [];

export function pushModal(id: symbol) {
  modalStack.push(id);
}

export function removeModal(id: symbol) {
  const index = modalStack.lastIndexOf(id);
  if (index >= 0) modalStack.splice(index, 1);
}

export function isTopModal(id: symbol) {
  return modalStack.at(-1) === id;
}

The keyboard handler can then ignore events belonging to a higher layer.

useEffect(() => {
  if (!open) return;

  function onKeyDown(event: KeyboardEvent) {
    const dialog = dialogRef.current;
    if (!dialog || !isTopModal(layerId.current)) return;

    if (event.key === "Escape") {
      event.preventDefault();
      event.stopPropagation();
      onDismiss();
      return;
    }

    keepTabInside(event, dialog);
  }

  document.addEventListener("keydown", onKeyDown, true);
  return () => document.removeEventListener("keydown", onKeyDown, true);
}, [onDismiss, open]);

Escape is only one exit. Every modal should also contain a visible close, cancel, or completion control. A touch screen-reader user cannot depend on a physical Escape key.

Some workflows need to protect unsaved work. Do not silently disable Escape and leave the user trapped. Let Escape request closure, then present an understandable confirmation path with an explicit way to remain or leave.

B-06

Restore focus to a meaningful place

When a dialog closes, a keyboard user needs to know where they are. In the common case, focus returns to the trigger.

Capture the active element before focus enters the modal. On cleanup, prefer an explicit trigger reference, then the captured element. Check that the target is still connected because the action inside the dialog may have removed it.

const previousFocusRef = useRef<HTMLElement | null>(null);

useLayoutEffect(() => {
  if (!open) return;

  previousFocusRef.current =
    document.activeElement instanceof HTMLElement
      ? document.activeElement
      : null;

  return () => {
    const target = triggerRef?.current ?? previousFocusRef.current;

    if (target?.isConnected) {
      target.focus();
    }
  };
}, [open, triggerRef]);

Restoring the old node is not always correct. If a delete-confirmation dialog removes the row containing its trigger, focus might move to the next row, the previous row, or the collection heading. The feature owns that decision because only the feature understands what remains meaningful after the action.

Nested dialogs make restoration a stack. Closing the child returns focus to the control inside the parent that opened it. Closing the parent later returns focus to the page trigger. A single global `previousFocus` variable cannot represent both transitions.

B-07

Portals solve layout problems, not modal behavior

React portals let a dialog escape ancestors with `overflow: hidden`, transforms, and local stacking contexts. The dialog can render under a stable container near the document body while remaining a child of the same React tree.

if (!open) return null;

return createPortal(
  <div className="modal-layer" role="presentation">
    <div className="modal-backdrop" />
    <div
      aria-labelledby={titleId}
      aria-modal="true"
      ref={dialogRef}
      role="dialog"
      tabIndex={-1}
    >
      {children}
    </div>
  </div>,
  document.body,
);

The physical DOM position changes; the React relationship does not. Context still works, and events from portal content still bubble through the React tree. A click inside the modal can therefore reach an ancestor React handler even though the DOM nodes are far apart. Backdrop dismissal and parent click handlers need deliberate event boundaries.

A portal also does not make the background inert. For a custom ARIA dialog, the modal manager must disable all content outside the active layer while preserving any pre-existing `inert` or `aria-hidden` state. That bookkeeping becomes more difficult when several React roots, third-party overlays, or nested dialogs share the page.

B-08

Scroll locking is shared state

Setting `document.body.style.overflow = "hidden"` works in a quick desktop demo. It can also shift the layout when the scrollbar disappears, lose the user's scroll position on mobile browsers, and unlock the page too early when a nested dialog closes.

A minimum scroll manager needs reference counting and exact restoration of the styles it changes.

let scrollLocks = 0;
let previousOverflow = "";
let previousPaddingInlineEnd = "";

export function lockPageScroll() {
  scrollLocks += 1;
  if (scrollLocks !== 1) return;

  const body = document.body;
  const scrollbarWidth =
    window.innerWidth - document.documentElement.clientWidth;

  previousOverflow = body.style.overflow;
  previousPaddingInlineEnd = body.style.paddingInlineEnd;
  body.style.overflow = "hidden";

  if (scrollbarWidth > 0) {
    body.style.paddingInlineEnd = `${scrollbarWidth}px`;
  }
}

export function unlockPageScroll() {
  scrollLocks = Math.max(0, scrollLocks - 1);
  if (scrollLocks !== 0) return;

  document.body.style.overflow = previousOverflow;
  document.body.style.paddingInlineEnd = previousPaddingInlineEnd;
}

Even this is only a baseline. Production code must handle mobile viewport behavior, overscroll, safe areas, right-to-left layouts, fixed headers, scrollbar gutters, and the case where another part of the application also owns a scroll lock. The dialog panel must separately support long content without hiding its title or close control.

B-09

Nested dialogs expose every weak assumption

Imagine an edit-profile modal that opens a second dialog to confirm discarding changes. While the confirmation is open:

  • The confirmation is the only active modal.
  • Focus is trapped inside the confirmation, not the parent.
  • Escape closes only the confirmation.
  • The parent remains mounted but is not interactive.
  • Page scrolling remains locked after the confirmation closes.
  • Focus returns to the control in the parent that opened the confirmation.
  • Closing the parent later restores focus to the original page trigger.

That behavior requires a stack of focus scopes, layer identities, inert regions, and scroll locks. Z-index establishes only paint order. Two independent modal components can look correctly stacked while both listen for Escape, both fight to restore focus, and both believe they own the body styles.

Avoid nested dialogs when the interaction can become one clearer flow. A confirmation can sometimes replace the parent dialog's contents, or an inline warning can keep the decision in context. When nesting expresses a real hierarchy, use one overlay system that coordinates every layer.

B-10

The native dialog element reduces the surface area

The HTML `dialog` element, opened with `showModal()`, enters the browser's top layer and makes the rest of the containing document inert. Browsers also provide modal Escape behavior and a backdrop. That is a meaningful improvement over recreating the platform from a generic `div`.

useEffect(() => {
  const dialog = dialogRef.current;
  if (!dialog) return;

  if (open && !dialog.open) dialog.showModal();
  if (!open && dialog.open) dialog.close();
}, [open]);

return (
  <dialog aria-labelledby={titleId} ref={dialogRef}>
    <h2 id={titleId}>{title}</h2>
    {children}
    <button type="button" onClick={onDismiss}>
      Close dialog
    </button>
  </dialog>
);

Native behavior does not remove product decisions. The application still owns the accessible name, appropriate initial focus, visible dismissal, React state synchronization, post-action focus destination, long-content layout, animation, and verification with the browsers and assistive technologies it supports.

Treat `dialog` as a stronger platform primitive, not as permission to stop testing.

B-11

When an established component library is safer

Building the educational version is useful because it reveals the contract. Shipping that version as shared infrastructure is a different decision.

An established accessible component library is usually safer when the application has any of these conditions:

  • More than one dialog or more than one team consuming the primitive.
  • Nested dialogs, menus that open dialogs, popovers inside dialogs, or other overlay combinations.
  • Server rendering, hydration, exit animations, or portals into custom containers.
  • Complex forms with dynamic fields, validation, asynchronous state, or destructive actions.
  • Mobile Safari, touch screen readers, zoomed layouts, or virtual keyboards in the support matrix.
  • A design system that needs consistent modal behavior across several products.
  • No dedicated capacity to maintain focus, inertness, scroll, and browser edge cases over time.

Libraries such as React Aria Components, Radix Primitives, Base UI, and Ariakit have already invested in focus scopes, dismissal, portals, and overlay coordination. The important selection criteria are not screenshots or default styles. Review the keyboard contract, accessible naming API, nested-overlay behavior, scroll strategy, server-rendering support, maintenance history, bundle cost, and the ease of applying your own semantic design tokens.

A library does not make the finished feature accessible by itself. The team must still provide a useful title, choose appropriate initial and restored focus, preserve visible close controls, write understandable validation messages, and test the actual content. The library takes ownership of difficult infrastructure; the product team keeps ownership of meaning.

B-12

Verify the interaction, not the markup

Automated checks can catch a missing name or an invalid ARIA attribute. They cannot decide whether focus landed on the safest action or whether returning to a deleted trigger makes sense.

Manually test the complete sequence:

  • Open the dialog using only the keyboard and confirm that focus moves inside once.
  • Tab forward and backward through every interactive element, including dynamically revealed controls.
  • Press Escape and confirm that only the active dialog closes.
  • Close with every visible action and verify the correct restoration target.
  • Try to focus, click, and scroll the background while the dialog is open.
  • Open and close a nested dialog in both orders and check the focus history and scroll lock.
  • Test short content, long content, validation errors, loading states, and no tabbable children.
  • Review at 320 percent-equivalent narrow layouts, with browser zoom, and with reduced motion.
  • Use at least the screen reader and browser combinations in the product's support policy.
  • Confirm that an error, removed trigger, route change, or interrupted animation still cleans up global state.

Accessibility failures here are state-management failures, lifecycle failures, and architecture failures. They deserve the same design attention as data ownership or error recovery because they determine whether a user can complete the workflow at all.

B-13

The engineering lesson

The difficult part of a modal is not rendering it. It is maintaining a coherent interaction boundary while the React tree, DOM tree, focus order, document scroll, and overlay stack all change.

Build one manually when the goal is learning, when the behavior is deliberately small, or when the platform `dialog` element satisfies the supported use case and the team can own the remaining edge cases. Prefer a mature accessible primitive when the modal is shared, nested, animated, or business-critical. Reusing hard-won infrastructure is not avoiding engineering; it is choosing where the team should carry risk.

Explore Build on Strong Foundations