Debug Accessibility interaction model
Explain focus, names, descriptions, landmarks, and live regions. Then apply it to a realistic product screen where a user action, browser behavior, and rendering timing all matter.
Answer Strategy
Accessibility debugging is the question that exposes whether you treat keyboard and screen-reader users as a first-class path. The interview-grade rule: every interactive element must be reachable by Tab, every group must have a role and an accessible name, every transient surface (dialog, menu, popover) must trap focus and restore it on close. Symptoms — Escape does nothing, Tab leaks behind a modal, focus lands at the top of the page after close — are all the same root: nobody owns the focus contract.
Diagnose by walking the page with the keyboard, then with VoiceOver/NVDA, then with the accessibility tree open in DevTools. Each step finds different bugs. Keyboard finds reachability and focus-restoration gaps. Screen readers find missing names, missing roles, and live regions that announce wrong urgency. The accessibility tree finds aria attributes pointing at non-existent ids and roles that conflict with the underlying tag.
Volunteer the senior tradeoff. Native elements (button, dialog) are correct by default; using divs with role attributes is opt-in correctness that breaks easily. The HTML <dialog> element handles modal semantics natively, but legacy browsers and styling constraints often push teams to roll their own. Pick the path explicitly. The reference shows the rolled-your-own pattern with all the focus-management glue; the same component would be three lines if the team adopted <dialog> with showModal().
Reference Implementation: Side Panel With Focus Trap And Restoration
BrokenSidePanel reproduces the keyboard failure mode (no role, no focus management). SidePanel fixes the contract: role=dialog, focus trap, Escape close, focus restore.
// Symptom: a "side panel" pattern is keyboard-broken. Mouse users can open,
// edit, and close it; keyboard users can open it but Tab cycles to the
// (visually hidden) page behind, Escape does nothing, and after closing,
// focus lands at the top of the document instead of the trigger.
// BROKEN version uses a div as a button, no role, no labelled relation,
// and no focus management. Mouse-only thinking.
function BrokenSidePanel({ open, onOpen, onClose }: {
open: boolean;
onOpen: () => void;
onClose: () => void;
}) {
return (
<div>
<div onClick={onOpen} className="trigger">Open settings</div>
{open && (
<div className="panel">
<div onClick={onClose}>X</div>
<p>Settings body</p>
<button>Save</button>
</div>
)}
</div>
);
}
// FIXED version names the role, manages focus deterministically, traps Tab
// inside the dialog, restores focus on close, and labels the panel for
// screen readers. The exact same mouse path still works.
type SidePanelProps = {
open: boolean;
onClose: () => void;
};
export function SidePanel({ open, onClose }: SidePanelProps) {
const dialogRef = React.useRef<HTMLDivElement | null>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(null);
const titleId = React.useId();
React.useEffect(() => {
if (!open) return;
previousFocusRef.current = document.activeElement as HTMLElement | null;
const dialog = dialogRef.current;
if (!dialog) return;
const focusable = dialog.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
(focusable[0] ?? dialog).focus();
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
} else if (event.key === 'Tab') {
if (focusable.length === 0) {
event.preventDefault();
dialog.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
previousFocusRef.current?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
>
<header>
<h2 id={titleId}>Settings</h2>
<button type="button" onClick={onClose} aria-label="Close settings">
×
</button>
</header>
<p>Settings body</p>
<button type="button">Save</button>
</div>
);
}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.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
function Host() {
const [open, setOpen] = React.useState(false);
return (
<>
<button type="button" onClick={()=> setOpen(true)}>Open settings</button>
<SidePanel open={open} onClose={()=> setOpen(false)} />
</>
);
}
describe('SidePanel a11y', ()=> {
it('exposes role=dialog with aria-modal and an accessible name', async ()=> {
const user= userEvent.setup();
render(<Host />);
await user.click(screen.getByRole('button', { name: /open settings/i }));
const dialog = screen.getByRole('dialog', { name: /settings/i });
expect(dialog).toHaveAttribute('aria-modal', 'true');
});
it('moves focus into the dialog and restores it on close', async () => {
const user = userEvent.setup();
render(<Host />);
const opener = screen.getByRole('button', { name: /open settings/i });
await user.click(opener);
const close = screen.getByRole('button', { name: /close settings/i });
expect(close).toHaveFocus();
await user.keyboard('{Escape}');
expect(opener).toHaveFocus();
});
it('Tab cycles inside the dialog, not to the dimmed page', async () => {
const user = userEvent.setup();
render(<Host />);
await user.click(screen.getByRole('button', { name: /open settings/i }));
await user.tab();
expect(screen.getByRole('button', { name: /save/i })).toHaveFocus();
await user.tab();
// Wraps back to the close button, not to the host's Open settings.
expect(screen.getByRole('button', { name: /close settings/i })).toHaveFocus();
});
});Interviewer Signal
Shows whether you understand accessibility interaction model as an operating model, not as memorized trivia.
Constraints
- Use one concrete browser or React-facing example.
- Name the failure mode a production user would notice.
- Keep the first answer under two minutes before expanding.
Model Answer Shape
- Start with the rule: focus, names, descriptions, landmarks, and live regions.
- Tie the rule to ownership: what runs in render, what runs after paint, what is external state, and what must be cleaned up.
- Close with the smallest test, trace, or code review check that would catch the bug.
Tradeoffs
- A short interview answer is easier to follow, but a senior answer must still name the edge case.
- Framework vocabulary helps only after the browser or language rule is clear.
Edge Cases
- Slow devices where timing bugs become visible.
- Repeated user actions before async work settles.
- Browser defaults that differ from custom component behavior.
Testing And Proof
- Unit-test the pure decision when possible.
- Use an interaction test for focus, keyboard, timing, or cleanup behavior.
Follow-Ups
- How would this change in a React component?
- What would you log or profile if this broke in production?