After every navigation, the page heading wears a focus ring. Nothing is broken: Blazor's
FocusOnNavigate moved keyboard focus to the <h1> on purpose, and your
:focus-visible rule drew exactly the outline it was told to draw.
FocusOnNavigate gives the heading tabindex="-1" and focuses it so screen-reader users
learn that the page changed. A ring rule that lists [tabindex] then matches the heading — and with
higher specificity than the h1:focus { outline: none } you reach for first. Keep the focus, lose the
ring, with a selector that wins: h1[tabindex]:focus-visible { outline: none; }, or leave
tabindex="-1" elements out of the ring rule altogether.
The error
There is no message; there is a screenshot with a teal box around every page title, and this, from Blazor's own JavaScript:
// aspnetcore/src/Components/Web.JS/src/DomWrapper.ts
function focusBySelector(selector: string) {
const element = document.querySelector(selector) as HTMLElement;
if (element) {
if (!element.hasAttribute('tabindex')) {
element.tabIndex = -1;
}
element.focus({ preventScroll: true });
}
}
Why it happens
Routes.razor in the template carries <FocusOnNavigate RouteData="routeData" Selector="h1" />.
A heading is not focusable, so the script above adds tabindex="-1" — focusable by script, skipped
by Tab — and focuses it. Good behavior; keep it.
Now the stylesheet. ClinicLive's ring rule and its season-two "fix":
:is(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
outline: 3px solid var(--primary);
outline-offset: 2px;
}
h1:focus { outline: none; }
:is() takes the specificity of its most specific argument. [tabindex] is an attribute
selector, 0,1,0; add :focus-visible and the ring rule scores 0,2,0. h1:focus scores
0,1,1. The heading now has a tabindex attribute, so the ring rule matches it and wins. Browsers apply
:focus-visible to script-moved focus when the last interaction was the keyboard or there has not been
one yet — which is precisely the state of a freshly loaded page in a screenshot harness, or a desktop window that
just opened.
The fix
/* pocket.css — FocusOnNavigate gives the h1 tabindex="-1" so screen readers
land on it, which also makes it match [tabindex] above. */
h1[tabindex]:focus, h1[tabindex]:focus-visible { outline: none; }
h1[tabindex]:focus-visible scores 0,2,1 and beats the ring rule. The alternative is to fix the ring
rule itself, so that programmatic focus targets never get a ring anywhere:
:is(a, button, input, select, textarea, summary,
[tabindex]:not([tabindex="-1"])):focus-visible { outline: 3px solid var(--primary); }
What not to do: remove FocusOnNavigate, or set outline: none globally. Both trade a
cosmetic bug for an accessibility one.
Where it bit us
Twice. Season two, Part 2 (tag polish-02 in
the repo): the first screenshot after the new design
system showed the ring on every title, and h1:focus went into app.css. Season three,
Part 2 (tag pocket-02): the Windows
build of the Pocket app showed the same ring, because pocket.css carried the same
[tabindex] ring rule and the specificity arithmetic above is what actually decides. Same bug, new
face — and, by that arithmetic, the attribute-selector version is the one to keep.
Frequently asked
- Why does my h1 have tabindex=-1 in a Blazor app?
- The FocusOnNavigate component in Routes.razor focuses the element matching its Selector after each navigation. A heading is not focusable, so Blazor's script sets tabindex to -1 first, which makes it focusable by script but not by the Tab key, and then calls focus on it.
- Should I remove FocusOnNavigate to get rid of the focus ring?
- No. Moving focus to the heading is how screen-reader users learn that a new page has loaded. Keep the component and change the CSS instead, either with h1[tabindex]:focus-visible { outline: none } or by excluding tabindex=-1 elements from your ring rule.
- Why does h1:focus { outline: none } not remove the ring?
- Specificity. A ring rule such as :is(a, button, [tabindex]):focus-visible scores 0,2,0 because :is takes its most specific argument, and h1:focus scores only 0,1,1. Once the heading has a tabindex attribute the ring rule matches it and wins, so the override needs the attribute selector too.
More decoded errors in the Fixes category; the redesign this came from starts at From Prompt to Polish, Part 1.