The Warning Is Telling the Truth, and Your Favorite Fix Probably Isn’t Helping
Every front-end developer eventually meets the angry mustard-colored console message about aria-hidden and focus. It shows up across Angular, Bootstrap, Ionic, and phpMyAdmin alike, so the search results you land on are a graveyard of well-meaning but wrong workarounds. The most popular fixes — the blur() one-liner, wrapping your close handler in a setTimeout, or stripping the aria-hidden attribute entirely — all do the same thing: silence the console while leaving the underlying problem untouched.
That problem is a real person using a screen reader whose focus is about to fall into a void. The warning exists because the browser caught your markup producing an invalid state, and it’s corrected that state on its own — overriding your aria-hidden in the accessibility tree it actually ships to assistive technology.
If you can migrate to the native <dialog> element and call .showModal(), do that. The browser handles the entire focus dance for you, and this whole class of bug mostly evaporates. (You’ll still need to handle the case where the element focus should return to has been removed from the DOM — no browser can guess that.) Everything else here assumes you’re stuck with a component library or design system you can’t replace this quarter.
Order of Operations Is the Whole Fix
The entire solution fits in one sentence: focus has to leave a region before that region becomes hidden or inert. Most modal code already contains the right pieces — they’re just in the wrong sequence. The fix is purely a reordering, with one step nearly everyone skips: making the closing overlay itself inert.
// WRONG: the order most modal code ships with
function closeModal() {
// background hidden while focus is STILL inside it → ghost focus
overlay.setAttribute('aria-hidden', 'true');
overlay.classList.add('fade-out');
overlay.addEventListener('transitionend', () => overlay.remove());
// focus restored after the hide already committed; too late
triggerButton.focus();
}
// RIGHT: hand the page back, then focus leaves, then the region goes inert
function closeModal() {
// un-inert FIRST: inert blocks focus, so the trigger can't receive focus while the background is still inert
background.removeAttribute('inert');
// move focus OUT before anything gets hidden
triggerButton.focus();
// inert, not aria-hidden, on the CLOSING shell
overlay.setAttribute('inert', '');
overlay.style.pointerEvents = 'none';
overlay.classList.add('fade-out');
overlay.addEventListener('transitionend', () => overlay.remove());
}
The wrong version reads top-to-bottom exactly how you’d narrate closing a modal out loud. But the browser applies the hide the moment that statement executes, before the focus move on the next line even happens. It’s all one synchronous task — the damage is in the ordering and the invalid state between those two statements, not a literal gap in time.
Get the order right and the result is reassuringly boring. The user hits Esc, hears focus land back on the button that opened the thing, and carries on. Get it wrong and they land on <body>, hear silence or just the page title, and have to Tab from the top of a long page back to wherever they were. That second experience is what the warning exists to prevent, and it’s exactly what blur() delivers every time.
Chrome Isn’t Just Warning You — It’s Overruling You
The word “warning” does a lot of damage. It tells you this is advisory, yellow, non-blocking — but by the time you see the message, the browser has already decided you were wrong and shipped a different accessibility tree than the one you wrote.
Open the modal that triggers it and inspect the Elements panel. Your aria-hidden="true" is sitting right there on the background wrapper, untouched. The DOM inspector isn’t lying. Switch to the Accessibility panel, though, and you’ll see the tree Chrome actually handed the operating system’s screen-reader APIs: the subtree you told it to hide is still there, still exposed, still fully readable.

#page wrapper carries aria-hidden="true" (see the console warning naming the focused button), yet its entire subtree — including the focusable links — is still exposed in the accessibility tree, because a focused element remains inside it.Blink read your attribute, saw the focused node living inside that subtree, and walked back up the focused node’s ancestor chain, ignoring your aria-hidden the whole way. The moment focus leaves, the pruning snaps back and the region hides as you asked. The state you think you shipped — that region invisible to assistive tech — exists only in your Elements panel and your head.
That gap between the two panels is the whole bug, and it stems from a paradox baked into aria-hidden. The attribute pulls content out of the accessibility tree, but it doesn’t pull that content out of keyboard focus order. Two different systems, with nothing keeping them in sync.
So an element can be fully focusable and completely imperceptible at once. When Tab lands on it, you’ve created ghost focus: the screen reader fires a focus event for a node it’s been told doesn’t exist, looks it up, finds nothing it’s allowed to describe, and says nothing. From the user’s side: focus moved, a control lit up, and their screen reader went silent. They don’t know if the app broke, if their assistive tech crashed, or if they did something wrong — and the only way out is tabbing blind and hoping something eventually speaks.
How Long This Has Been Happening
Chromium has been quietly patching this for far longer than the warning has existed. The console message landed in two installments, timing reconstructed from when bug reports clustered. The open-time variant — scolding you about an element that “just received focus” — appeared around Chrome 127 in summer 2024, clustering in July and August across MUI #43106, Ant Design #50170, and Flowbite #943. The close-time “retained focus” wording arrived with Chrome 131 in late 2024; Bootstrap #41005, filed November 5, caught it live in Beta and Nightly builds but not stable yet, and Angular #30187 followed in December. Two waves, one underlying behavior.
That behavior is old. Chromium was already exposing focusable aria-hidden nodes back in early 2020. ARIA WG issue #1185 records Chrome accessibility engineer Aaron Leventhal proposing exactly that, so users could “at least hear where they are tabbing to, instead of complete silence.” The pattern he was defending against goes back even further, to teams slapping aria-hidden on <body> or a giant wrapper when a modal opened — which, through portal and markup mistakes, sometimes hid the modal too, locking a screen reader out of the whole page. Arguments about that failure appear as far back as Bootstrap #29769 in 2019.
The honest framing: your teardown code was broken years before any of this reached your console. The repair was happening silently the whole time — which is exactly why nobody fixed it. Firefox and Safari don’t surface a comparable console warning for this, as far as testing shows; whatever each engine does about focused content inside a hidden subtree, it does without telling you. Chrome decided to make you feel it, and that call was right. A silent fix lets broken code ship forever, because the browser papering over your mistake looks indistinguishable from your code being correct. Loud is uncomfortable, and loud is honest.
If Chrome is repairing your tree, the question stops being “how do I make the message go away?” and becomes “at what exact instant does my code produce a focused node inside a hidden region?” There are four distinct instants, each with its own shape.
One warning, four different entrances
Every path ends at the same crime scene: focus parked inside a subtree that just went aria-hidden. But you arrive by four different routes, and without knowing which one you’re on you’ll reach for the wrong fix. Use this quick triage to find yours.
- Close-time race: Fires during a modal’s fade-out. You’ll see the “retained focus” wording.
- Open-time inversion: Fires the instant a modal opens, often with “just received focus” wording.
- Composition turf war: Involves a
<select>,popover, or dropdown nested in a<dialog>. Turns fatal under React 19. - Focus left the page: Fires with no page change when you Alt-Tab or switch tabs with an overlay open.
The close-time race: hiding before focus moves
You click the close button. The fade begins. For the duration of the CSS transition, focus still sits on that button, which lives inside the overlay the library just marked hidden. Chrome logs the“retained focus” warning. Roughly seventy percent of the reports this editor sees arrive through this door.
The anatomy is on full display in phpMyAdmin #19793: a focused <button.btn-close> inside a hidden <div.modal>. The button that owns focus is a descendant of the thing being hidden, and nothing has moved that focus yet.
Bootstrap’s architecture keeps this bad window open by design. It historically restores focus to the triggering element on the hidden.bs.modal event, which only fires after the transition finishes. During every frame of the fade, a hidden modal holds a focused button with no code to relocate it.
That exact scenario landed in Bootstrap #41005. The maintainers’ initial attempt, PR #41867, swapped in inert to close the gap, but it was closed unmerged in June 2026. Bootstrap 6 opens modals with native showModal(), placing the dialog in the top layer and making the rest of the document implicitly inert, so the manual inert toggling was never needed. If you’re still on 5.x, the failing pattern is precisely what you’re shipping.
The same shape appears in MUI #43106, Shoelace #2335, and Angular #30187. They all make the same reasonable-looking choice: hide first so the animation can start, tidy focus later. The cure is ordering, detailed a few sections below.
The open-time inversion: hiding before focus arrives
Run the same defect in reverse. The overlay opens, the library marks the entire background aria-hidden="true" so screen readers ignore the page behind the modal. But the button the user just clicked lives in that background and still holds focus for one beat before anything moves it into the dialog. Hidden region, focused node inside, and you get the “just received focus” wording.
Flowbite #943 sharpens the problem: focus gets sent to an input inside a dropdown that hasn’t finished revealing, so the target is hidden at the instant it receives focus. Ant Design #50170 shows the plainer flavor: a modal mounted to <body> with the trigger still lit behind the newly-hidden backdrop.
This is the close-time race mirrored in time. Instead of hiding a region focus hasn’t left, you’re hiding a region focus hasn’t yet escaped. The fix is the same ordering discipline, applied on the way up instead of the way down.
The turf war: nested components fighting over modality
Open a <dialog>. Inside a form, add a <select>. The user opens the <select>, picks an <option>, and it closes. But now two components, each believing it’s the one true modal layer, are both shipping the same hide-others logic and neither knows the other exists.
shadcn #5953 documents the whole saga: a popover inside a <dialog>, each applying its own background-hiding. Developers bounce between modal={true} (console spam) and the alternative (an unclickable calendar), with no good middle option. Annoying, but survivable.
React 19 makes it fatal. Radix #3701, filed October 2025, carries the title Select inside Dialog causes an aria-hidden focus freeze. React 19’s unmount timing shifted just enough that when the inner Select tears down, focus drops to <body> for a moment. The parent <dialog> reads that as a click outside itself, re-hides itself with the user’s focus still inside, and keyboard navigation dies. Not a warning. A page you can’t Tab through anymore.
The issue is still claiming victims. shadcn-ui #10074 from March 2026 pins the mechanism: Radix’s internal hideOthers, from the aria-hidden package, walks every body-level sibling of the Select’s portal and marks them hidden before focus has moved off the trigger.
The architecture that merely prints a warning in the close-time race produces an unusable page here. This class can’t be fixed by ordering alone. You have to tell the two primitives to stop both being modal, which needs its own careful walkthrough.
The user walked out: focus leaves the page
Nothing on the page changed. The user had a menu open, hit Alt+Tab, or switched browser tabs. On teardown, the focus bookkeeping stranded an aria-hidden state with no live focus to reconcile against.
Material Web #5760 only fires when the menu is open and focus goes to another tab or window. That’s Google’s own component library tripping on the rule Google’s browser enforces. Ionic #30240 is the routing cousin, firing on navigation between tabbed pages with no modal in sight.
This class exists to make a point, not to be solved. If the maintainers of the browser can’t keep their own widgets clean, the problem is architectural, not a skill issue.
Look at the four side by side and the shared shape is unmistakable. Close-time, open-time, turf war, or a user tabbing away: a region became hidden while focus was still doing business inside it. Four costumes, one bug. Which raises the only question that matters: what did the Internet tell everyone to do about it?
The fix that isn’t one
Search the warning and the top results all point to the same “solution”: a single line dropped into the close handler. It silences the console and satisfies CI, but it does nothing for the person on the other side of the screen.
// the internet's favorite one-liner
element.addEventListener('hide.bs.modal', () => {
// "fixes" the warning
document.activeElement.blur();
});
Calling blur() by itself doesn’t send focus anywhere sensible—it sends it nowhere. The browser then picks the only available target, <body>, and the user is left stranded at the top of the DOM with no focused element inside the hidden subtree. The warning clears because nothing meaningful is focused at all.
Mouse users won’t notice. Keyboard and screen reader users will: the next Tab press restarts from the top of the entire page, and focus has to be tabbed through every header and nav item before it returns to where the user was interrupted. That’s a direct WCAG 2.4.3 violation, not a fix.
To be fair to blur() itself: it isn’t the problem. Blurring to a deliberate target, like the trigger element, is perfectly valid. The damage comes from blurring to nothing and leaving the user on <body>.
The appeal of the quick fix
I shipped that exact one-liner. It was late 2024, a Chrome update had suddenly made the warning appear across half the modals in our app, and the console was unreadable during crunch. The Stack Overflow answer took ninety seconds to find. I dropped it into a global handler, the red went away, the tests stayed green, and I moved on feeling slightly clever about it.
Months later, I sat in on a usability session. A screen reader user closed one of our modals—a routine operation that my one-liner covered. The reader went quiet. They pressed Tab to get back to their task, and i watched them tab through the entire page structure, voice by voice, to reach the point where the modal had interrupted them.
The warning I silenced was the one signal in the codebase speaking for that user. I’d muted it to keep the console tidy. The real issue wasn’t the blur() call—it was treating a warning about user experience as a warning about developer logs.
Why the other folk remedies fail similarly
Timing hacks are the second most common suggestion and the most insidious, because they sometimes work. The idea is to wrap the focus restoration in a setTimeout or requestAnimationFrame so it runs after the hide transition finishes.
// bet on the render finishing first
requestAnimationFrame(() => triggerButton.focus());
The shadcn community is unusually candid about this approach; discussion #5953 basically says: add a delay, and you’ll still occasionally see the warning, but it works most of the time. That “mostly” is a gamble on paint timing, which wins on a fast machine with a warm cache. Under CPU load, on a low-end Android, or with React’s concurrent scheduler splitting the work, it loses—and you’ve shipped a broken, intermittent state that won’t reproduce locally. Those half-committed states are exactly what WCAG 4.1.2 aims to prevent.
Stripping the aria-hidden attribute looks like addressing the root cause but does the opposite. Some delete it from markup; others wire a MutationObserver to remove it every time the library sets it. The warning genuinely goes away—because nothing is hidden anymore. While the modal is open, background content is exposed to the screen reader, so the user can Tab out and operate page controls behind it. That defeats the modal’s purpose and creates an illogical focus order, another WCAG 2.4.3 failure.
Then there’s modal={false} in Radix and shadcn. Setting this changes the component into a legitimate non-modal dialog—a valid pattern in itself. Used purely to silence the warning while keeping a visual backdrop, it creates something that looks modal but doesn’t behave modally, because the focus trap is removed entirely. As Radix #3811 shows, in Safari focus can tab out of the non-modal dialog, and Radix interprets that as an outside interaction and closes the dialog while a user is filling in a form.
The remaining improvisations deserve brief mention. Putting a temporary tabindex="-1" on <body> and dumping focus there is just blur() with extra steps. Filtering the warning out of the console is the software version of taping over the check-engine light.
The asymmetry that causes all this
The bad advice isn’t malicious, and good guidance does exist: Scott O’Hara has documented modal best practices for years, and MDN’s <dialog> documentation covers the correct model. But it rarely surfaces when you’re facing a deploy deadline and a red console. The correct material is quiet; the popular answers are optimized for one goal—clearing the warning—and that asymmetry is what makes it so easy to do real harm.
A clean console was never the goal. Every one of these fixes reduces the warning count while making the product worse for the users the warning was intended to protect.
The teardown contract, boiled down
The entire fix reduces to a rule short enough to keep in your head. On close, focus leaves the closing region before that region is hidden, and it must land somewhere real—never on an inert node and never on <body>.
The second half trips people up. inert blocks focus, so if your trigger sits inside the background you inert-ed on open, you must lift that inert first; otherwise, .focus() is a silent no-op and focus stays stranded inside the dialog. The ordered steps are:
- remove
inertfrom the background; - restore focus to the stored trigger synchronously, before any hide-state touches the DOM;
- apply
inertandpointer-events: noneto the closing overlay shell itself, so its fade-out runs on an element that’s visually present but dead to focus and the accessibility tree; - unmount when the transition ends.
If your trigger lives outside the inerted region, the first two steps commute, which is why you’ll see the focus-first order in some codebases. Keeping background-first is the safe default because it works either way. At open time, capture the return target—document.activeElement—before you move focus into the dialog; once focus is inside, the element you wanted to return to is gone.
The third step is the one most people get wrong, even after learning to avoid blur(). The common advice is “just restore focus first.” That’s necessary but not sufficient. Restore focus to the trigger and you’ve cleared the warning, but the overlay is still fading out for another 200 milliseconds—still in the DOM, still focusable, still in the accessibility tree. A screen reader can catch it on the way down; VoiceOver’s cursor will touch that ghost content if you let it. Inert-ing the dying shell is what actually closes the hole. You don’t suppress the warning during the fade; you make the fading element inert so there’s nothing to warn about.
Why frameworks get the order wrong
The frameworks commit the hide before they restore focus for a structural reason, not carelessness. In React, the state change that adds your hidden class or aria-hidden attribute applies during render. The focus-restoration code tucked into a useEffect cleanup runs after paint, a frame later—a real gap in time. The browser gets handed hidden-with-focus-inside, fires the warning, does its tree repair, and only then does your .focus() call run. You wrote the two operations in the right order in your source; React scheduled them in the wrong order at runtime. The fix is to move the focus call out of the after-paint effect and run it before the hide commits. Vue has the same issue through nextTick() and Transition-hook ordering, Angular CDK through its FocusTrap timing; the cause is identical, only the API names change.
The vanilla version
Strip away the framework and the contract is easy to see because there’s no scheduler between you and the DOM. This is the reference for jQuery, Web Components, or plain JavaScript.
class ModalController {
// where focus goes home to
#trigger = null;
// the sibling subtree we inert while open
#background = null;
open(dialog) {
// corollary: capture BEFORE we move focus in, or it's lost
this.#trigger = document.activeElement;
this.#background.setAttribute('inert', '');
dialog.hidden = false;
dialog.querySelector('[autofocus], button, [href], input')?.focus();
}
close(dialog) {
// STEP 1: hand the page back FIRST. inert blocks focus, so if the trigger lives inside the background, focusing it while the background is still inert is a silent no-op. Un-inert, then focus.
this.#background.removeAttribute('inert');
// STEP 2: focus goes home synchronously, before any hide-state lands.
// This is the line whose ORDER the warning is really about.
this.#trigger?.focus();
// STEP 3: the dying shell is inert, not aria-hidden. It can fade out in peace: unreachable by Tab, invisible to AT, no clicks.
dialog.setAttribute('inert', '');
dialog.style.pointerEvents = 'none';
// CSS drives the fade
dialog.classList.add('is-closing');
// STEP 4: unmount when the animation ends.
// Three traps:
// (1) transitionend bubbles from child elements (guard on e.target)
// (2) it never fires at all when there's nothing to wait for (duration + delay both 0) or the close is interrupted (transitioncancel)
// (3) you must NOT use { once: true } here — a bubbled child event would consume the one-shot listener before the dialog's own transition ever finishes.
// Remove listeners by hand, and only after accepting the dialog's own event.
const done = () => {
dialog.hidden = true;
dialog.classList.remove('is-closing');
dialog.removeAttribute('inert');
dialog.style.pointerEvents = '';
};
const finish = (e) => {
// a child's transition bubbled up; ignore it
if (e && e.target !== dialog) return;
dialog.removeEventListener('transitionend', finish);
dialog.removeEventListener('transitioncancel', finish);
done();
};
const style = getComputedStyle(dialog);
const dur = parseFloat(style.transitionDuration) || 0;
const delay = parseFloat(style.transitionDelay) || 0;
if (dur + delay <= 0) {
// no transition to wait for (e.g. a reduced-motion CSS rule zeroed it)
done();
} else {
dialog.addEventListener('transitionend', finish);
dialog.addEventListener('transitioncancel', finish);
}
}
}
Nothing clever is happening here, and that’s the point. The trigger is stored on open, the page is handed back before focus moves, focus is sent home before a single hide-state lands, and the overlay is inert for the whole duration of its own exit animation.
One caveat before production: the single #trigger slot holds exactly one return target, which is fine for one modal at a time but wrong the moment modals stack or a rapid open-close-open overlaps. For that, use a stack of triggers rather than a field—the first of the edge cases below. The transitionend bookkeeping is the least pleasant part of this; if you’d rather JavaScript own the animation, drive the fade with the Web Animations API instead, where element.animate(...).finished hands you a promise and the listener cleanup disappears. The CSS-transition version is kept here because it’s how the overwhelming majority of affected code in the wild is written, and the guard code is the honest cost of that approach.
The React version
The same contract, fighting the scheduler. The invariant is unchanged: focus has to land on the trigger before the state change that inerts or hides the region commits. Three things break that by default in React.
First, people capture the return target too late. If you grab document.activeElement in an effect that runs after isOpen flips, an autofocus effect may already have moved focus into the dialog, so you store the wrong element. Capture it in the handler that opens the dialog, before flipping state.
Second, people restore focus in a cleanup effect that runs after paint, so the browser sees hidden-with-focus-inside first and warns.
Third, and most subtle: if the background’s inert is itself driven by state, setIsOpen(false) followed on the next line by trigger.focus() won’t work. React batches the state update, so the DOM still has the trigger inside an inert container when .focus() runs, and the focus silently fails—the same no-op as in vanilla. The cleanest fix is to not route background inertness through render state at all. Whether the page behind a modal is inert is an imperative side effect, not view data: toggle the attribute directly, or use native <dialog> and let the top layer make it implicit. If you’re committed to keeping it in state, this is the case flushSync was built for.
function useModalTeardown() {
const triggerRef = useRef(null);
// the wrapper you inert while the modal is open
const backgroundRef = useRef(null);
// capture in the OPEN handler, before state flips — not in a post-open effect, where an autofocus effect may already have stolen focus
const open = useCallback((setOpen) => {
triggerRef.current = document.activeElement;
if (backgroundRef.current) backgroundRef.current.inert = true;
setOpen(true);
}, []);
// Restore focus BEFORE the state update that hides/inerts the region.
// The background's inert is toggled imperatively here (not via state), so the trigger is reachable the instant we un-inert and there's no batching between the un-inert and the focus call.
const close = useCallback((setExiting) => {
// hand the page back
if (backgroundRef.current) backgroundRef.current.inert = false;
// move focus home first...
triggerRef.current?.focus();
// ...then commit the exiting/hidden state
setExiting(true);
}, []);
return { triggerRef, backgroundRef, open, close };
}
// The exiting shell renders inert while a CSS class runs the fade.
// Guard the unmount the same way the vanilla version does: transitionend bubbles (check e.target) and won't fire with no transition (duration 0).
function ModalShell({ exiting, onDone, children }) {
const onEnd = (e) => { if (e.target === e.currentTarget) onDone(); };
return (
<div
inert={exiting ? '' : undefined}
className={exiting ? 'modal is-closing' : 'modal'}
onTransitionEnd={exiting ? onEnd : undefined}
>
{children}
</div>
);
}
If the background’s inertness must be React state, ordering alone can’t save you because the un-inert and the focus call are separated by batching. flushSync forces the state update that removes the background’s inert to commit to the DOM before you call .focus().
// commit the un-inert to the DOM NOW
flushSync(() => setIsOpen(false));
// trigger is reachable, so this lands
triggerRef.current?.focus();
// then start the fade
setExiting(true);
Note what’s actually wrapped: the state update whose DOM effect you depend on, not the .focus() call, which is already synchronous. That distinction is why a bare flushSync(() => trigger.focus()) does nothing. flushSync has a real batching cost, throws if you call it during render, and most teardown code avoids it entirely by toggling background inertness imperatively or moving to native <dialog>. Reach for the imperative toggle first; reach for flushSync only when inertness must stay in state.
If you only change one thing in an existing React modal: capture the trigger in the open handler, and run the focus restoration before the hide state commits rather than in an after-paint effect. That reorder is the whole difference between the warning and no warning, and between your screen reader user landing on the trigger versus <body>.
Said plainly: inert is the right instrument and aria-hidden was always the wrong one for this job. aria-hidden removes a subtree from the accessibility tree but leaves it fully focusable—the entire ghost-focus hole. inert removes it from the accessibility tree, sequential focus navigation, and pointer events (the HTML spec is explicit, and MDN’s inert reference documents the same three effects), which is why Chrome’s message points you at it. One trap: never put inert on an ancestor of a top-layer element or you’ll freeze the top-layer element itself, dialog included. Apply it to sibling subtrees you want dead, not to a wrapper containing your live dialog.
Comparing major implementations
How major implementations sequence this varies more than you’d hope.
| Library | When focus is restored | Hiding mechanism | Verdict |
|---|---|---|---|
Native <dialog> | Browser-internal, on close | Top layer, implicit inertness | Best default* |
| React Aria | Synchronous, layout-effect timing | FocusScope + inert direction | Strongest custom |
| Radix | Pre-unmount via onCloseAutoFocus | hide-others / aria-hidden | Acceptable, React 19 caveat |
| Bootstrap 5.3 | On hidden.bs.modal, after the fade | aria-hidden on wrapper | The failing pattern |
| Floating UI | Managed by FloatingFocusManager | Moved to inert suppression | Good direction |
Native <dialog> earns “best default,” not “flawless”: it eliminates the ghost-focus class outright, but focus return on close only works if the previously-focused element is still there and focusable, and autofocus placement inside the dialog has had cross-browser wrinkles worth testing. Animate its exit now with @starting-style and transition-behavior: allow-discrete.
Bootstrap 5.x is the canonical failing pattern here, and version 6 abandons it for native showModal(). Radix is defensible: onCloseAutoFocus restores before unmount, which is fine, with the honest asterisk that React 19’s unmount timing changes introduced the freeze mentioned earlier. React Aria’s FocusScope is the one to study if you’re building your own, because it restores synchronously via layout-effect timing and sidesteps the whole race by construction.
For most of us maintaining existing design systems with deep portal architectures, migrating to native <dialog> isn’t an option this quarter. That’s exactly who the four-step teardown contract is for.
Four edge cases the contract must survive
Each one breaks the UI in its own way if you skip it.
The trigger no longer exists. The kebab menu opened a dialog, and the dialog deleted the row the kebab lived in. Restoring focus to a detached element silently drops you to <body>. Store a fallback—the list container or the nearest heading with tabindex="-1"—and send focus there instead.
Modals stack. A modal opens another modal. Each layer stores the element that opened it, so restoration chains and the closes unwind like a stack, innermost first. This is the case the single-slot trigger storage can’t handle.
The user left the page. An Alt+Tab or a tab switch with the overlay still open. Don’t run restoration against a stale activeElement on window blur; wait and reconcile focus when the window comes back.
There’s no transition to wait for. If you gate the unmount on transitionend, that event never fires when no transition runs—exactly what happens under prefers-reduced-motion: reduce or when a user closes fast enough to interrupt the fade. Run the teardown immediately when the computed transition duration is zero, or the closing shell sits in the DOM, inert, forever.
Run it yourself
Everything above is a claim you should be skeptical of, especially from someone who once shipped a bad fix. So the argument is bundled into a runnable demo: plain vanilla JavaScript, no framework, so no scheduler is distorting the operation order. Four modals sit side by side, each closing a different way.
Variant 1 is the naive teardown: aria-hidden is set while the close button still holds focus, and focus is only restored in the transitionend handler. Variant 2 applies the blur() hack. Variant 3 hides synchronously but defers focus restoration into a setTimeout. Variant 4 executes the contract from earlier—un-inert, focus home, inert the closing shell, unmount. Each logs document.activeElement at every lifecycle tick—open, close-start, transition-end—straight to the console, right next to Chrome’s own output.
Open the console first, then use the keyboard (Escape, or Tab to the close button and hit Enter) rather than the mouse. In variant 1, watch for two things: whether the “retained focus” warning fires during the fade, and where the activeElement log puts focus while it fires. The prediction is deliberately mild: Chrome’s own tree repair keeps the focused content exposed, so a screen reader user is quietly saved by the browser even though the markup is wrong. The warning fires, but the user is mostly fine. That gap between “broken code” and “user noticed” is precisely why this pattern shipped everywhere for years.
Variant 2 deserves scrutiny. Close it and look at the activeElement log immediately after the close: it reads body, as expected. Run this under NVDA or VoiceOver and you get the scenario from earlier—silence, or a useless page title announcement. Press Tab and focus restarts from the very first focusable element on the page; the demo’s deliberately long scaffold forces you through the entire header and nav. The console, meanwhile, is clean. No warning. That’s the trap on one screen: a green console and a stranded user.
Variant 3 is the instructive case. It warns on every close: the hide commits synchronously while focus is still inside, and the setTimeout relocates focus only in a later task. Chrome detects the invalid intermediate state and warns, even if that state never paints as a visible frame. A delay changes where the warning appears, not whether it appears—and, as discussed, under real CPU load or concurrent rendering, the relocation becomes intermittent failure instead of a clean warning.
Variant 4 is meant to be boring. A warning-free console and an activeElement log that reads the trigger button immediately, at the close-start tick, not three ticks later. With a screen reader, the handoff is plain: focus lands on the control that opened the modal, that control is announced, and the next Tab continues onward. Nobody gets stranded.
Why automated checks miss it
If CI is your safety net, this pattern will escape it. Run an axe or Lighthouse scan against all four variants and several of the broken ones pass. The scanners photograph the resting state of the markup, and in the resting state everything is orderly: aria-hidden is gone, the dialog is closed, there’s nothing to photograph. The defect doesn’t live in any single frame—it lives in the two hundred milliseconds between frames, in the ordering of operations. A scanner that samples static state will never catch a bug that exists only in the film between the shots. This is why the problem needs a human with a keyboard and a screen reader, and why it slipped past every automated gate.
If you run the demo on an AT or browser version that behaves differently than predicted, file it on the demo repo. Disagreement from a real NVDA build beats unsupported predictions.
Assigning blame, fairly
The library maintainers have a legitimate grievance. Chrome dropped a scolding warning into two unpublished waves, aimed at teardown code that had followed the idiomatic pattern of its era–fade the overlay out, restore focus on transitionend—the approach baked into Bootstrap’s hidden.bs.modal timing and countless tutorials. When the warning landed, those maintainers found their trackers full of reports on behavior that hadn’t changed on their end. Issues sat unresolved or ping-ponged between “that’s a Chrome bug” and “that’s your integration,” because both were half-true. If that happened to your component library, it was unfair, and there’s no pretending otherwise.
But the browser’s position holds. Hiding focusable content from assistive tech violated what WAI-ARIA implied, and the APG modal-dialog pattern states the focus-management contract explicitly. Chromium had been silently repairing this defect for years—a stance on the record in ARIA WG issue #1185 from 2020. The decisive evidence is behavioral: libraries started migrating to inert only once the console got loud. Silent repair moved nobody. The warning shipped and within months Shoelace, Ant Design, and Floating UI were reworking their teardown logic. Loudness was the only force that made the ecosystem act.
Worst off are the app developers under a zero-console-warnings rule. They inherit the warning from a library they don’t control, apply the top-ranked fix to satisfy the rule, ship blur(), and then fail an accessibility audit for the original defect and the WCAG violation the hack introduced. They pay twice for a problem originating two layers up. The folk wisdom that the warning is harmless circulates in community threads because people are downstream of the same bad search results as everyone else—not because they’re careless.
The judgment and the pattern
Here’s where this lands. The browser is right on the merits: you cannot hide a focused control from a screen reader and call the result accessible. The rollout communication was poor, and the frustration about that is legitimate. But the fix belongs in the component layer, not the browser and not a hundred app-level patches, because the component is the only place that owns both the focus and the hiding. Libraries will patch and re-patch; the invariant is what survives.
This also outlasts its own specifics. The console string will be reworded. Bootstrap already closed its case durably, dropping the inert patch for native <dialog> in version 6. Firefox and Safari repair silently today, and could go loud tomorrow. The standardization direction is no longer speculative: ARIA WG issue #2422 worked through 2025 and closed around March 2026, its minutes acknowledging existing heuristics like disregarding aria-hidden on <body>. The ordering problem itself, though, is architectural; every overlay system not yet written will meet it. Fresh instances like shadcn-ui #10074 from March 2026 make that point.
The welcome future is native <dialog> and the top layer absorbing the custom-modal category wholesale, making this piece a historical curiosity about a solved problem. Until then, resist the urge to mute the warning. It is not noise layered on top of your architecture—it’s your architecture speaking plainly about what it does to someone the moment you stop watching. The person I once watched Tab back through an entire page header in a silence I had caused wasn’t in the room when I shipped. The warning was the only voice they had.



