Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 6 min read

I pressed Tab through all 5,495 stops on our site: the order is perfect and 373 of them show no focus ring

I have grepped our 76 public pages for accessibility before and found one h1 each, zero skip links and a toast region on every marketing page. A grep tells you what is in the HTML. It does not tell you what happens when

I have grepped our 76 public pages for accessibility before and found one h1 each, zero skip links and a toast region on every marketing page. A grep tells you what is in the HTML. It does not tell you what happens when somebody actually presses Tab, so this time I pressed Tab.

const tab = async () => {
  await page.cdp("Input.dispatchKeyEvent", { type: "rawKeyDown", windowsVirtualKeyCode: 9, key: "Tab", code: "Tab" });
  await page.cdp("Input.dispatchKeyEvent", { type: "keyUp",      windowsVirtualKeyCode: 9, key: "Tab", code: "Tab" });
};

Worth knowing: CDP key events do move focus. They do not scroll. An earlier audit of mine tried to test arrow-key scrolling this way and window.scrollY never moved, so I assumed synthetic keys were useless for keyboard work. They are useless for the keys the browser handles as scrolling, and fine for the one that walks the focus ring.

The shape of the tab order

Across all 76 public pages:

5,495 tab stops          median 72 a page    min 51    max 116
stops before <main>       7 on every page, all 76
stops inside the footer  36 on every page, all 76
positive tabindex         0
tabindex="-1"            81
focusable but invisible 104

Two of those numbers being identical on all 76 pages is the system working: one header, one footer, no page doing anything special. It also means 43 of a median 72 stops, 60 per cent of them, are the same furniture every time.

Here is the beginning of the walk on /glossary, one line per press:

1  A  Nakodo home
2  A  How it works
3  A  Guides
4  A  Blog
5  A  Pricing
6  A  About
7  A  Open the app
8  A  Home              <- first element inside <main>
9  A  Affiliate link
10 A  Brand integration

Seven presses to reach the content. On every page. Then, at the other end, 36 more to walk a footer that is the same on all of them. We have no skip link, and this is the measurement that makes that concrete: seven presses, 76 times, to get past something the reader has already seen.

That seven is also not a constant. The browser I measured in is signed in, so the header renders one button. The HTML we serve renders two, "Sign in" and "Start free", because the server cannot know who you are on a static page. A signed-out visitor gets eight presses, and a signed-in one gets seven after hydration swaps the buttons.

The 104 invisible-but-focusable elements are mostly one thing: the hamburger button that is md:hidden at desktop width. Chrome correctly skips it, which is the whole point of hiding with display: none rather than opacity. The 81 negative tabindexes are the five tour buttons on the home page, which move in and out of the tab order as the tour advances with tabIndex={showing ? 0 : -1}, plus a wizard heading and a section that are focus targets rather than stops.

The inversions that are not bugs

I also checked, for each consecutive pair of stops, whether the second one is higher up the page than the first. 447 of those across the site, five or six on every page.

Every one is the footer. It has five columns of links, so the tab order goes down column one, jumps back to the top for column two, and so on. That is the correct reading order and the correct tab order, and a geometric check flags it as a backwards jump every time.

That is the lesson of this particular metric: "tab order follows visual order" is not a rule you can test by comparing getBoundingClientRect().top values, because any multi-column layout breaks it legitimately. The number worth testing is positive tabindex, which forces an order unrelated to the DOM, and ours is zero.

373 stops with no focus ring

Then I checked what each stop looks like when it is focused. Our whole site has exactly one focus style:

:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px }

One rule, every link, and it works. Except on 373 of 5,495 stops, where the element matches :focus-visible, has an outline-width of 2px, an outline-color of our ring colour, and an outline-style of none.

373 of 5,495 stops show nothing when focused
median 4 a page, minimum 2, maximum 18
0 of the 76 pages are clean

The two every page has are the "Open the app" button in the header and its twin in the footer. The worst pages are the engagement rate calculators at 18 each: ten number inputs, four buttons and four links, which is to say the entire interactive part of the page.

The cause is one class, and it is a Tailwind 4 change. Our button's base styles start like this:

"… transition-colors outline-none select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring …"

That reads as "no outline normally, a 2px ring outline when focused by keyboard", and in Tailwind 3 it was. In Tailwind 4 the two utilities compile to this:

.outline-none { --tw-outline-style: none; outline-style: none }
.focus-visible\:outline-2:focus-visible { outline-style: var(--tw-outline-style); outline-width: 2px }

The width utility does not set a style, it defers to the variable, and the variable was set to none by the class sitting next to it. So the focused element gets a 2px wide, correctly coloured outline drawn in no style at all.

Tailwind 4's own source shows what each utility does, including the one we should be using:

outline-hidden   --tw-outline-style: none; outline-style: none
                 + @media (forced-colors: active) { outline: 2px solid transparent; outline-offset: 2px }
outline-none     --tw-outline-style: none; outline-style: none
outline-solid    --tw-outline-style: solid; outline-style: solid

outline-hidden is the v4 name for what outline-none meant in v3, and it carries the Windows high contrast fallback that the transparent outline trick existed for in the first place. outline-none in v4 means the stronger thing: there is no outline, and nothing you write afterwards with a width utility is going to bring it back.

Nine of our components pair the two classes: button, input, textarea, select, tabs, scroll area, two brand components and a wizard heading. Every one of them ships without a keyboard focus indicator.

Check your own app in one line

If you migrated a shadcn-style component set to Tailwind 4, paste this into a console:

[...document.querySelectorAll("*")].filter((el) => {
  const c = String(el.className);
  return /(^|\s)outline-none(\s|$)/.test(c) && /focus-visible:outline-\d/.test(c);
}).length;

Anything above zero is elements whose focus ring has been switched off by a rename. The fix is outline-hidden instead of outline-none, or focus-visible:outline-solid alongside the width, and it is one find-and-replace in nine files.

What this changes

  • outline-none becomes outline-hidden in all nine components today. It is a WCAG 2.4.7 failure on every page we have, and the only reason it survived is that keyboard focus is invisible to anybody testing with a mouse.
  • A skip link goes in the layout. Seven presses to reach the content is seven more than necessary, and we now know it is exactly seven on exactly 76 pages.
  • A test that asserts no element carries both classes, because this will come back the next time somebody copies a component in from a v3 example.

The audit that found it took four minutes of key presses. Our contrast audit measured 13,403 pieces of text and found one failing badge. This one measured 5,495 key presses and found 373 failures, all from one word in one class name, which is roughly the ratio I have come to expect between how carefully a thing was written and how badly a framework rename can undo it.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.