( Operable / WCAG 2.4.3 )
Modal opens without moving focus into it
What is this issue?
A modal dialog becomes visible in the DOM (its hidden attribute is removed, its display changes, or an equivalent show/hide toggle fires), but no code moves keyboard focus to anywhere inside it. Focus remains on whatever element the user was last interacting with on the page behind the dialog, even though that page is now visually covered and, in a correctly built modal, meant to be inert while the dialog is open.
This is a behavioral defect, not a structural one: the dialog’s markup can be entirely correct, with proper roles and a focusable container, and this failure can still occur if the JavaScript driving it simply never calls .focus() on anything inside the dialog when it opens. The problem lives entirely in what the open action does, not in what the dialog’s HTML looks like.
Why does this matter?
A sighted mouse user sees a modal appear instantly and knows exactly what’s on screen and where to click next. A keyboard or screen reader user gets no equivalent signal unless focus itself moves; without that, they have no reliable way to detect that anything changed, since visual appearance alone tells them nothing.
The practical consequence is a keyboard user continuing to interact with content that’s supposed to be temporarily off-limits: tabbing through form fields or links on the page behind the modal, possibly submitting the wrong form or triggering an action the modal was specifically meant to interrupt, all while genuinely unaware a dialog is open and waiting for their attention.
Who is affected?
- Keyboard users: receive no indication a modal has appeared, since focus never moves and nothing else in a keyboard-driven interaction signals the change.
- Screen reader users: depend on focus movement to trigger the announcement of new content; without it, a screen reader may never announce the modal’s role or content at all, leaving the user unaware anything opened.
- Motor impairments: users navigating via switch device or head pointer face the identical lack of signal, and any extra Tab presses spent interacting with now-supposedly-inert background content cost them disproportionately more effort than a mouse user’s single glance would.
What users experience
Elena uses TalkBack on her Android tablet to manage her utility account. She taps “Delete account,” which opens a confirmation dialog, but the underlying JavaScript only toggles a CSS class to show it, without ever moving focus inside. TalkBack, hearing no focus change, keeps announcing the account settings page she was already on. She continues swiping through what she believes is the settings page, unaware a confirmation dialog is now covering it, and eventually swipes into a button that happens to be the dialog’s own “Cancel” control, accidentally dismissing a warning she never knew existed, with no idea her delete request is still pending confirmation somewhere she can no longer see.
How do I fix it?
Move focus to the dialog, its container (with tabindex="-1" if it has no naturally focusable content) or its heading, the moment it becomes visible. This works because moving focus is what actually triggers an announcement for screen reader users and repositions the keyboard’s active point for everyone navigating without a mouse, giving both groups the same “something just changed here” signal a sighted mouse user gets automatically from the dialog simply appearing on screen.
Return focus to the element that opened the dialog when it closes, rather than leaving focus wherever it happened to land inside the dialog or resetting it to the top of the page. This restores the user’s exact working position from before the dialog opened, matching what a sighted mouse user experiences by default when a modal disappears and their cursor is still sitting where they last clicked.
Code Examples
function openDialog() {
dialog.hidden = false;
// Focus never moves - stays wherever it was on the page behind it
}let triggerElement;
function openDialog(trigger) {
triggerElement = trigger;
dialog.hidden = false;
const heading = dialog.querySelector('h2');
heading.setAttribute('tabindex', '-1');
heading.focus();
}
function closeDialog() {
dialog.hidden = true;
triggerElement.focus();
}Giving the heading a temporary tabindex="-1" makes it programmatically focusable without adding it to the page’s normal Tab sequence, and calling .focus() on it immediately triggers a screen reader announcement of the heading text, an instant, accurate signal that the dialog opened and what it’s about. Storing the triggering element and returning focus to it on close completes the round trip, leaving the user exactly where they started once the dialog is dismissed.
Framework Examples
React modals commonly manage this focus movement inside an effect tied to the dialog’s open state, since that’s the natural place to run a side effect exactly once when visibility changes.
function ConfirmDialog({ isOpen, onClose, triggerRef, title, children }) {
const headingRef = useRef(null);
useEffect(() => {
if (isOpen) {
headingRef.current?.focus();
} else {
triggerRef.current?.focus();
}
}, [isOpen, triggerRef]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title" ref={headingRef} tabIndex={-1}>{title}</h2>
{children}
</div>
);
}
The single effect handles both directions of the focus round trip: it runs once when isOpen flips true, moving focus into the heading, and runs again when it flips false, returning focus to whatever triggered the dialog; React’s dependency array ensures it fires exactly at the state transitions where focus needs to move, without a separate open handler and close handler each remembering to do it themselves.
Common Mistakes
Mistake: “The dialog has role=‘dialog’ and aria-modal=‘true’, so screen readers will announce it automatically.” ARIA attributes describe what an element is; they don’t move focus. A correctly marked-up dialog that nothing ever calls .focus() on still produces no announcement for most screen reader users, since the announcement is typically triggered by focus arriving somewhere inside the newly revealed content, not by the attributes existing in the DOM.
Mistake: “We call .focus() on the modal’s close button so users can dismiss it quickly.” Focusing the close button on open technically moves focus, satisfying the letter of this rule, but skips past any orientation the dialog’s heading or content would have given the user about what they’re confirming. It also means the very first thing many users hear is “close,” which invites accidentally dismissing a dialog they haven’t read yet.
Mistake: “This is covered by our automated redflag-modal-focusable check passing, so we don’t need to test this separately.” That check only confirms the modal container has a tabindex attribute, the structural precondition for being focusable at all. It says nothing about whether your open-dialog code actually calls .focus() on anything, which is exactly the gap this separate, behavior-focused rule exists to catch.
How RedFlag Detects This
AI-assisted: flagged by the optional Review Labels feature. This is distinct from the automated Modal is missing focus management DOM check: this rule covers focus management behavior (whether focus actually moves on open and returns on close), which requires observing interaction over time rather than inspecting a static DOM snapshot, so it’s surfaced through RedFlag’s Review Labels workflow and confirmed by a human reviewer rather than emitted as a deterministic rule.
False negative: a candidate that the AI reviewer doesn’t surface for a given modal simply isn’t flagged, the same limitation any probabilistic detection has; subtle or infrequently-triggered modals are more likely to be missed than prominent, commonly-tested ones. False positive: a modal that manages focus correctly through an unusual pattern the AI model isn’t confident about can still be surfaced as a candidate for review, requiring a human to confirm it’s actually fine. Manual step: every candidate this workflow surfaces is confirmed or rejected by a human reviewer before it becomes a recorded violation; treat the AI flag as a starting point for verification, not a final verdict.
Manual Testing
- Open the page in Chrome or Firefox with NVDA or VoiceOver running.
- Trigger every modal dialog on the page.
- Listen for an announcement immediately after triggering it. It should describe the dialog (its role and a name or heading), not silence or continued announcement of the page behind it.
- Confirm the next Tab or Shift+Tab press moves within the dialog’s own controls, not back out to the page behind it.
- Close the dialog and confirm focus returns to the element that opened it, not to the top of the page or nowhere identifiable.
Related WCAG Success Criteria
2.4.3 Focus Order: Components must receive focus in an order that preserves meaning and operability. A modal that opens without moving focus fails this directly: the next logical focus position, entering the newly revealed dialog, never happens, breaking the sequence a keyboard user depends on to understand what’s currently active.
Related Issues
Modal is missing focus management is this rule’s structural companion: the DOM-level check for whether the modal container even has the tabindex plumbing this rule’s behavioral fix depends on being present.
Focus is trapped inside a component covers the next stage of the same dialog lifecycle: once focus correctly enters a modal, it also needs a way back out, which is a separate requirement this page doesn’t cover.
Manual check flagged an incorrect tab order and Element has a positive tabindex cover related focus-sequencing defects in the same 2.4.3 Focus Order family, applied to static page content rather than dialog open/close behavior.
Dialog or modal has no accessible name covers the naming half of the same dialog lifecycle: even a modal with correct focus management still leaves screen reader users guessing at its purpose if it announces only “dialog” with no name.
References
Frequently asked questions
How is this different from Modal is missing focus management?
They cover the same dialog lifecycle from two different angles. Modal is missing focus management (redflag-modal-focusable) is a structural DOM check: does the modal container even have a tabindex attribute, the basic plumbing needed before it can receive focus programmatically at all. This rule is a behavioral check: once that plumbing exists, does the code actually call focus() on open, and does focus return to the trigger element on close.
Can a modal pass the structural check and still fail this one?
Yes, and it is a common combination. A modal container can have tabindex="-1" correctly set up, passing the structural redflag-modal-focusable check, while the JavaScript that opens the modal never actually calls .focus() on it, leaving the container focusable in principle but never actually focused when the modal appears.
Where should focus land inside the modal: the container, the heading, or the first field?
Either the modal container itself (with tabindex="-1") or its heading works well, since both immediately orient the user to the modal's purpose. Landing focus on the first form field skips that orientation step entirely, so it is a weaker choice for anything beyond the simplest modals, especially ones with a heading that explains what the dialog is for.
Does focus need to return to the exact trigger element when the modal closes?
Ideally yes. Returning focus to the specific button or link that opened the modal preserves the user's exact position in the page, which matches what a sighted mouse user experiences by default. Returning focus to a less specific location, like the top of the page, is a partial fix that avoids losing focus entirely but still costs the user their place.