Representative interview topic

Frontend Interview: How Do You Build an Accessible Modal Dialog?

FrontendMedium
Offer.cc Editorial TeamPublished Updated

Question

Build a reusable accessible modal dialog that opens from a button, visually and behaviorally blocks the page behind it, exposes a useful accessible name, keeps keyboard focus in the active dialog, closes through an explicit control and Escape, and returns focus correctly. Explain initial-focus policy, native versus custom implementation, destructive confirmations, stacked dialogs, framework lifecycle risks, and testing.

Prompt and Applicable Context

Build a reusable modal dialog for a frontend UI coding interview. The caller supplies a title, optional short description, body content, open state, and confirm and close callbacks. The dialog must support mouse, touch, keyboard, and assistive-technology users. Visual centering and a dimmed backdrop are required, but the main problem is behavior: content outside the active dialog is unavailable, the dialog has a useful name, focus enters and remains within it, every user has an explicit way to close it, and focus returns to a logical place afterward.

Assume current evergreen browsers support the native dialog API. Also explain how the contract would be implemented when an interviewer explicitly forbids the native element, an embedded webview lacks support, or an existing component library already owns a tested custom primitive. The reusable unit must not hard-code one initial-focus target because a form, a long informational dialog, and an irreversible delete confirmation need different choices.

The implementation does not include application-specific data fetching, animation design, or a full modal manager. Those become follow-ups. The acceptance criteria are observable: keyboard activation, focus placement, focus containment, accessible naming, close behavior, focus restoration, layering, and stable behavior through rerenders and unmounts.

What the Interviewer Evaluates

The first signal is whether the candidate defines “modal” as a behavioral contract. A centered panel with a high stacking value is only an overlay. A true modal makes the rest of the document inert, contains the tab sequence, exposes dialog semantics, and manages the complete focus lifecycle. Adding an ARIA attribute without making outside content unavailable creates a misleading accessibility tree.

The second signal is platform judgment. The native dialog element opened with showModal() enters the browser top layer, creates a backdrop, makes other elements in the same document inert, and supplies modal keyboard behavior. Opening it with show() or by setting its open attribute is non-modal. A strong answer uses the native primitive when the product's browser contract permits it, while still being able to state every invariant a custom implementation must reproduce.

The third signal is focus policy rather than a memorized “focus the first button” rule. A short form may focus its first invalid or primary field. A long document-like dialog should often focus a static heading with a programmatic focus target so the beginning and semantic structure remain available. An irreversible confirmation should initially focus the least destructive action. On close, focus usually returns to the opener; if that element was removed, the caller must choose the next logical work item.

The fourth signal is framework lifecycle correctness. Imperative browser state and declarative UI state must not drift. Calling showModal() twice, removing an open dialog before synchronizing state, closing it without updating the parent, or restoring focus to a stale node causes real bugs. Event listeners need cleanup, unique labels must remain stable, and server rendering must not call a browser API during render.

The final signal is verification quality. Automated accessibility checks can catch missing names or invalid attributes, but they cannot prove that focus lands on the right element for this workflow, that Tab and Shift+Tab remain contained, that Escape follows product policy, or that focus returns to a logical place after the opener disappears. Those need keyboard and screen-reader checks.

Questions to Clarify Before Answering

  • Is this truly modal? If users must keep interacting with the page, use a non-modal dialog,

popover, or inline panel. Do not label it modal merely because it floats above content.

  • May I use the native dialog element? If yes, use showModal() and preserve its default behavior.

If no, implement semantics, outside inertness, focus containment, Escape, and restoration explicitly.

  • What content appears inside? A simple form, a long structured document, and an alert require

different accessible descriptions and initial-focus choices.

  • Can the operation be reversed? For deletion or payment, focus Cancel or another least destructive

control. For a routine continuation dialog, the likely next action may be appropriate.

  • How may it close? Always provide a visible close or cancel control. Clarify Escape, backdrop

click, submit success, and whether unsaved input requires a confirmation step.

  • What should receive focus after close? Usually the opener. If creation removes or replaces that

node, identify a stable logical successor before writing the component.

  • Can dialogs stack? Prefer one active modal. If stacking is required, only the top dialog handles

dismissal, and closing it restores focus inside the dialog below it.

  • Which browsers and assistive technologies are in scope? This decides whether the native element

is sufficient, needs a compatibility layer, or must be replaced by an established tested primitive.

30-Second Answer Framework

“I would define six invariants: an accessible name, inert background, deliberate initial focus, a contained tab sequence, explicit and Escape dismissal, and logical focus restoration. On current browsers I would use the native dialog element with showModal() for the top layer, backdrop, inertness, and core focus behavior. Initial focus follows the task: a heading for long content, the relevant field for a form, and Cancel for an irreversible action. In React I would synchronize controlled state with guarded open and close effects, listen directly for cancel, and verify keyboard opening, both tab directions, dismissal, screen-reader naming, focus restoration, rerenders, and stacked dialogs.”

Step-by-Step Deep Dive

Start with invariants that are independent of React, CSS, or the native element:

text
OPEN
  exactly one active modal owns interaction
  outside content is visually obscured and behaviorally inert
  dialog has an accessible name from a visible title
  focus is inside the dialog on a purposefully selected target

WHILE OPEN
  Tab and Shift+Tab cannot enter the background document
  visible close or cancel control is reachable
  only the top modal handles a close request

CLOSE
  parent open state and browser dialog state agree
  focus returns to the opener if it exists
  otherwise focus moves to a predefined logical successor

Choose the primitive next. The native dialog is the default for a current-browser contract. Calling showModal() places it in the top layer, gives it a backdrop, and makes other content in its document inert. This avoids manually walking every focusable element, setting aria-hidden across application roots, and fighting stacking contexts. Calling show() or setting open produces a non-modal dialog, so those are not interchangeable shortcuts.

A custom dialog remains valid when the interview forbids the native element or the product already uses a mature component primitive. It must render a container with dialog semantics and an accessible name, set modal semantics only when outside interaction is actually blocked, make all background roots inert, contain focus, handle Escape, restore focus, and render above application stacking contexts. Portaling the panel to the document body helps with clipping and stacking, but a portal alone supplies none of those accessibility behaviors.

Use an initial-focus decision table:

  • For a short form, focus the first field that the user should act on, especially an invalid field

after validation.

  • For long text, lists, or tables, focus the title or another static element at the start with a

programmatic focus target. Do not flatten rich structure into one long accessible description.

  • For irreversible work, focus Cancel or the least destructive action.
  • For a simple acknowledgement or continuation, focus the most likely action when doing so cannot

trigger accidental harm.

The accessible name should normally reference the visible title. A short plain-text description may be referenced separately. Omit a single description reference for content containing several paragraphs, lists, or tables so assistive-technology users can navigate that structure. A close icon still needs an accessible name, and a visible close or cancel control must exist even when Escape is supported.

The following React example keeps the native browser state synchronized with controlled application state. Its description prop is intentionally a short string; complex content goes in children and is not assigned as one flattened description.

tsx
'use client'

import { useEffect, useId, useRef, type ReactNode } from 'react'

interface AccessibleModalProps {
  open: boolean
  title: string
  description?: string
  initialFocus: 'heading' | 'cancel' | 'confirm'
  children: ReactNode
  onConfirm: () => void
  onOpenChange: (open: boolean) => void
}

export function AccessibleModal({
  open,
  title,
  description,
  initialFocus,
  children,
  onConfirm,
  onOpenChange,
}: AccessibleModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null)
  const headingRef = useRef<HTMLHeadingElement>(null)
  const cancelRef = useRef<HTMLButtonElement>(null)
  const confirmRef = useRef<HTMLButtonElement>(null)
  const titleId = useId()
  const descriptionId = useId()

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

    if (open && !dialog.open) {
      dialog.showModal()
      const target =
        initialFocus === 'confirm'
          ? confirmRef.current
          : initialFocus === 'cancel'
            ? cancelRef.current
            : headingRef.current
      target?.focus()
    } else if (!open && dialog.open) {
      dialog.close()
    }
  }, [initialFocus, open])

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

    const handleCancel = (event: Event) => {
      event.preventDefault()
      onOpenChange(false)
    }
    const handleClose = () => {
      if (open) onOpenChange(false)
    }

    dialog.addEventListener('cancel', handleCancel)
    dialog.addEventListener('close', handleClose)
    return () => {
      dialog.removeEventListener('cancel', handleCancel)
      dialog.removeEventListener('close', handleClose)
    }
  }, [onOpenChange, open])

  return (
    <dialog
      ref={dialogRef}
      aria-labelledby={titleId}
      aria-describedby={description ? descriptionId : undefined}
    >
      <h2 ref={headingRef} id={titleId} tabIndex={-1}>
        {title}
      </h2>
      {description ? <p id={descriptionId}>{description}</p> : null}
      {children}
      <div>
        <button ref={cancelRef} type="button" onClick={() => onOpenChange(false)}>
          Cancel
        </button>
        <button ref={confirmRef} type="button" onClick={onConfirm}>
          Confirm
        </button>
      </div>
    </dialog>
  )
}

The guards around dialog.open prevent duplicate imperative calls during rerenders. The direct cancel listener matters because the event is cancelable and does not bubble. Preventing its default close lets controlled state change first; the next effect closes the native dialog. The close listener also repairs state if another native close path runs. Browser APIs stay inside effects, so server rendering only emits markup.

Keep dismissal policy explicit. A backdrop click is not automatically equivalent to Cancel. A form with unsaved work may ignore backdrop clicks, a lightweight picker may accept them, and an irreversible confirmation should not disappear from an accidental pointer event. If the product accepts backdrop dismissal, use a tested backdrop-region hit test and route it through the same controlled close path. An event-target check alone can mistake clicks on dialog padding for backdrop clicks. Never create a full-screen invisible close target that steals clicks intended for the dialog.

Native focus restoration normally returns to the invoking element. Application flow can override that only with a reason. When a dialog creates a new row and removes the “Add row” button, the first cell of the new row is a logical destination. Capture this policy at the caller, because the reusable dialog cannot infer what changed in the surrounding workflow.

For stacked dialogs, prefer changing content within one dialog. If a second modal is unavoidable, maintain a stack: only its top entry may close from Escape or backdrop, background dialogs remain inert, and closing the top entry restores focus to the control that opened it inside the previous dialog. A global Boolean cannot represent that relationship.

Test behavior, not just rendered attributes:

text
1. Open with Enter and Space; verify focus enters the intended target.
2. Tab from the last control and Shift+Tab from the first; verify background is unreachable.
3. Press Escape; verify one top dialog closes and controlled state becomes false.
4. Use the visible close and Cancel controls with keyboard, pointer, and touch.
5. Close normally; verify focus returns to the opener.
6. Remove the opener during completion; verify focus moves to the chosen logical successor.
7. Read with a screen reader; verify one useful title and no flattened rich description.
8. Open a destructive confirmation; verify initial focus is on the least destructive action.
9. Rerender repeatedly while open; verify no duplicate-open exception or focus reset.
10. Unmount during navigation; verify no listener leak or focus jump to the document body.
11. At 200% zoom and a small viewport, verify title, controls, and scrollable content remain reachable.
12. Run automated accessibility checks, then repeat the manual focus workflow they cannot prove.

High-Quality Sample Answer

“I would treat the modal as a focus and interaction state machine, not as a panel with a large stacking value. When it opens, the rest of the document must become inert, the dialog needs a name tied to its visible title, and focus must move to a target selected from the task. While open, keyboard navigation stays inside and a visible close or cancel control is always reachable. On close, focus returns to the opener unless the workflow replaced it, in which case the caller supplies a logical successor.

For current browsers I would use the native dialog element and call showModal(). That gives me the top layer, backdrop, background inertness, and core modal focus behavior. Setting open or calling show() would produce non-modal behavior, so I would not use either as a substitute. If the exercise forbids the native element, I would reproduce the same invariants with a dialog role, accessible name, real outside inertness, focus containment, Escape handling, a portal, and restoration, preferably via an existing tested primitive rather than new ad hoc focus-trap code.

Initial focus depends on content. I would focus the relevant field for a short form, a static heading for long structured content, and Cancel for an irreversible action. I would reference a short description only when it can be understood as one announcement; lists and multiple paragraphs stay navigable as structure.

In React I would synchronize controlled state with showModal() and close() inside effects, guard against duplicate calls, listen directly for the non-bubbling cancel event, and remove listeners on cleanup. Backdrop dismissal would be an explicit product policy. I would verify keyboard opening, both tab directions, Escape, explicit close, screen-reader naming, focus restoration when the trigger exists or disappears, repeated rerenders, stacked dialogs, zoom, and small viewports. Automated checks supplement that workflow but do not replace it.”

Common Mistakes

  • Styling a centered overlay and calling it modal → background controls remain reachable to

keyboard or assistive technology → Define and test inertness and the focus lifecycle.

  • Adding modal semantics without blocking outside interaction → the accessibility tree promises a

state that sighted pointer users do not experience → **Set modal semantics only when behavior is genuinely modal.**

  • Toggling the native open attribute → the dialog is displayed without showModal() behavior →

Use the correct modal method and synchronize it with application state.

  • Always focusing the first control → long content may start off-screen and destructive work may

focus the dangerous action → Choose initial focus from content structure and consequence.

  • Putting several paragraphs into one accessible description → screen readers announce an

unstructured block → Reference only a short description and leave rich content navigable.

  • Relying only on Escape → touch and switch users may have no clear dismissal path → **Include a

visible, named close or cancel control.**

  • Closing on every backdrop click → accidental pointer input discards work → **Make light-dismiss a

deliberate, testable product policy.**

  • Writing a manual focus trap before checking the platform or library → edge cases multiply around

disabled controls, DOM changes, portals, and nested dialogs → **Prefer the native element or a mature tested primitive.**

  • Calling showModal() during render or on every effect run → server rendering fails or the browser

throws and focus resets → Call imperative methods in guarded effects.

  • Restoring focus to a removed trigger → focus falls to the document body and keyboard context is

lost → Have the caller identify a logical successor when workflow changes the page.

  • Treating an automated scan as complete proof → it cannot judge focus order or workflow intent →

Perform the full manual keyboard and screen-reader sequence.

Follow-Up Questions and Responses

Follow-up 1: What changes if the native dialog element is forbidden?

Render a portal containing a dialog-role container with a visible-title reference and modal semantics. Make every background application root inert, not merely visually dimmed. Capture the opener, choose and set initial focus, contain both tab directions as focusable descendants change, handle Escape on the active dialog, restore focus, and clean up every mutation and listener. Explain why a maintained primitive is safer than reimplementing this cross-browser behavior for each product dialog.

Follow-up 2: How do you handle a form with validation errors?

Keep the dialog open. Move focus to the first invalid field or an error summary that links to invalid fields, expose each message through its field's accessible description, and preserve entered values. Do not announce every field error as one dialog description. After a successful submit, close only when the application result is committed, then move focus to the element that represents the result.

Follow-up 3: Should clicking the backdrop close the dialog?

Derive it from loss risk. A lightweight picker may allow it; a long form or destructive confirmation should usually require an explicit decision. If enabled, dismiss only when the pointer sequence begins and ends in the backdrop region, use the same state transition as Cancel, and test pointer down inside followed by pointer up outside so a drag is not mistaken for intent.

Follow-up 4: How do you animate close without breaking focus restoration?

Separate “requested close” from “removed from the DOM.” Mark the dialog as closing, stop new actions, play the exit transition, then call the native close path and update state before unmounting. Respect reduced-motion preferences and provide a bounded completion fallback. Focus restoration happens at the actual close boundary, not when opacity first changes.

Follow-up 5: What if a second dialog opens from the first?

Avoid it when a multi-step flow within one dialog is clearer. If required, store an ordered stack with an opener for each entry. Only the top entry responds to Escape or backdrop; lower dialogs remain blocked. Closing the child restores focus to its opener inside the parent, and closing the parent then restores focus to the page opener.

Follow-up 6: How do you prevent body scroll and layout shift?

Treat scroll locking as a visual and input policy separate from semantic inertness. Record the current scroll position, apply one centralized lock while the first modal is open, compensate for disappearing scrollbar width when needed, and release only after the last modal closes. Test mobile virtual keyboards, nested scroll regions, zoom, and route changes; reference-counted ownership prevents one dialog from unlocking the page beneath another.

Follow-up 7: How would you test this in CI?

Use component tests for controlled open and close transitions, labels, initial-focus branches, cancel handling, and cleanup. Add browser tests that activate the real trigger, move forward and backward through focus, press Escape, remove the opener, and exercise stacked dialogs. Run automated accessibility rules for structural regressions, then retain a manual matrix across representative screen reader, keyboard, zoom, touch, and high-contrast configurations because CI assertions cannot judge every announcement or workflow choice.

Public sources

Related questions