โ† Back to question bank
UI ComponentMidMedium#3001 ยท 45m

Build an accessible modal dialog

Design the behavior contract for an accessible modal dialog. Focus on state, keyboard interaction, empty/loading/error states, and how the component composes with product data.

Answer Strategy

Start with the interview thesis: a modal is open state, focus isolation, and a labelled dialog. If those three pieces are correct, the visual shell can change without breaking the user contract.

Name ownership before writing JSX. The parent owns open/closed state. The modal reports close intent. The dialog owns temporary focus behavior while it is mounted. Product code owns the save/cancel side effects. That split keeps the modal reusable and prevents hidden global state.

Then narrate the dangerous cases: initial focus, Tab and Shift+Tab loops, Escape, backdrop clicks, scroll locking, focus restoration, nested overlays, and tests that use roles and names instead of implementation selectors.

Reference Implementation: Accessible Modal Primitive

This React version keeps the state API small and makes focus behavior explicit. The executable sandbox below uses the same contract with plain DOM code so you can run it directly in the browser.

// Interview contract: the parent controls visibility; the modal reports intent.
// The component should not decide whether "save" succeeded or where to route next.
type ModalProps = {
  open: boolean;
  title: string;
  description?: string;
  onClose: (reason: 'escape' | 'backdrop' | 'cancel' | 'submit') => void;
  children: React.ReactNode;
  footer: React.ReactNode;
};

// Keep the focus trap strict: only currently usable interactive elements count.
const FOCUSABLE_SELECTOR = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(',');

function getFocusable(container: HTMLElement) {
  return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR))
    .filter((element) => !element.hasAttribute('disabled'));
}

function AccessibleModal({
  open,
  title,
  description,
  onClose,
  children,
  footer,
}: ModalProps) {
  const dialogRef = React.useRef<HTMLDivElement | null>(null);
  // Store the opener so closing the modal returns the user to their workflow.
  const previousFocusRef = React.useRef<HTMLElement | null>(null);
  const titleId = React.useId();
  const descriptionId = React.useId();

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

    previousFocusRef.current = document.activeElement as HTMLElement | null;
    const dialog = dialogRef.current;
    if (!dialog) return;

    // Initial focus should land inside the dialog, not stay behind the overlay.
    const focusable = getFocusable(dialog);
    (focusable[0] ?? dialog).focus();
    document.body.style.overflow = 'hidden';

    function onKeyDown(event: KeyboardEvent) {
      // Escape is a close intent. Prevent default so the browser does not
      // accidentally trigger another focused control while the dialog closes.
      if (event.key === 'Escape') {
        event.preventDefault();
        onClose('escape');
        return;
      }

      if (event.key !== 'Tab') return;

      const items = getFocusable(dialog);
      if (items.length === 0) {
        event.preventDefault();
        dialog.focus();
        return;
      }

      const first = items[0];
      const last = items[items.length - 1];

      // Cycle focus inside the modal. Without this, keyboard users can tab
      // into the dimmed page behind the dialog.
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }

    document.addEventListener('keydown', onKeyDown);
    return () => {
      // Cleanup is correctness: remove global listeners, unlock the page, and
      // restore focus to the element that opened the modal.
      document.removeEventListener('keydown', onKeyDown);
      document.body.style.overflow = '';
      previousFocusRef.current?.focus();
    };
  }, [open, onClose]);

  if (!open) return null;

  return (
    <div
      className="overlay"
      onMouseDown={(event)=> {
        // Only the backdrop closes the modal. Clicks inside dialog content
        // should not bubble into an accidental dismiss.
        if (event.target= event.currentTarget) onClose('backdrop');
      }}
    >
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        // The accessible name and description are the screen reader contract.
        aria-labelledby={titleId}
        aria-describedby={description ? descriptionId : undefined}
        tabIndex={-1}
        className="dialog"
      >
        <h2 id={titleId}>{title}</h2>
        {description && <p id={descriptionId}>{description}</p>}
        <div>{children}</div>
        <footer>{footer}</footer>
      </div>
    </div>
  );
}

Executable UI Sandbox

UI interview practice should behave like component documentation, not a static snippet. This uses the same isolation pattern as Storybook, Sandpack, CodeSandbox, and StackBlitz: editable source on one side, a sandboxed browser preview on the other. Edit the DOM code, run it, and verify focus, keyboard, pointer, and state behavior in the preview.

Browser sandbox
HTML, CSS, DOM events, focus, and keyboard behavior
Preview running...
Loading editor...

Testing Strategy

Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.

Contract
Assert role="dialog", aria-modal, accessible name, optional description, and parent-owned open/close state.
Focus
Verify initial focus, Tab and Shift+Tab wrapping, Escape close, backdrop close, and focus restoration to the opener.
Page effects
Check body scroll locking, cleanup on unmount, and that clicks inside dialog content do not dismiss accidentally.
Browser smoke
Run one Playwright/Cypress path on the real page because portals, z-index, and scroll behavior can pass unit tests but fail in a browser.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('traps focus, closes on escape, and restores focus', async () => {
  const user = userEvent.setup();
  render(<SettingsExample />);

  // Query by role and accessible name. That proves the component is usable,
  // not just that a class name exists.
  const opener = screen.getByRole('button', { name: /open settings/i });
  await user.click(opener);

  const dialog = screen.getByRole('dialog', { name: /workspace settings/i });
  expect(dialog).toBeInTheDocument();
  expect(screen.getByLabelText(/workspace name/i)).toHaveFocus();

  // Drive the same keyboard path a real user would use.
  await user.keyboard('{Tab}{Tab}{Tab}');
  expect(screen.getByRole('button', { name: /cancel/i })).toHaveFocus();

  // The close behavior must restore context, otherwise keyboard users are lost.
  await user.keyboard('{Escape}');
  expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
  expect(opener).toHaveFocus();
});

Interviewer Signal

Shows whether you can build components as interaction systems rather than visual boxes.

Constraints

  • Name the controlled and uncontrolled state.
  • Define keyboard and focus behavior.
  • Include loading, empty, disabled, and error states.

Model Answer Shape

  • Start with the accessibility role and interaction contract.
  • Separate rendering slots from state management.
  • Expose callbacks that describe user intent, not internal implementation details.

Tradeoffs

  • A headless primitive is reusable but slower to consume.
  • A product-specific component ships faster but can trap behavior in one use case.

Edge Cases

  • Focus after close, selection, deletion, or route change.
  • Large datasets and slow network responses.
  • Screen reader labels and live updates.

Testing And Proof

  • Keyboard path through the primary workflow.
  • A11y names, descriptions, and roles.
  • State transition after slow or failed data load.

Follow-Ups

  • How would this component be documented in a design system?
  • What props would you refuse to expose?