WCAG 2.2 added a success criterion that is unusually easy to measure: 2.5.8, Target Size (Minimum), level AA. Every target should be at least 24 by 24 CSS pixels, with a short list of exceptions. Unlike contrast, which needs a colour model, or focus order, which needs a real keyboard, this one is getBoundingClientRect() and a comparison.
So I ran it over all 76 public pages of Nakodo at a phone viewport, expecting a messy long tail of near misses. What came back was the opposite of messy, and the interesting part turned out to be the exception rather than the rule.
The setup
390 by 844, device scale factor 3, mobile flag on, set through the DevTools protocol rather than by resizing a window, so the emulation is the same every time:
await page.cdp("Emulation.setDeviceMetricsOverride", { width: 390, height: 844, deviceScaleFactor: 3, mobile: true }); Then on each page, every element that can be tapped, measured and classified:
const SEL = 'a[href], button, input:not([type=hidden]), select, textarea, summary, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])'; Anything with a zero box, display: none, visibility: hidden or a failing checkVisibility() is dropped. What is left is 5,199 targets across 76 pages, a median of 68 a page.
The classification matters more than the measurement, because 2.5.8 exempts targets "in a sentence or block of text". A link inside a paragraph is allowed to be as tall as its line, and padding it would wreck the paragraph. So each target is marked inline when its computed display starts with inline and it sits inside a p, li, dd, dt, td, th or blockquote. That splits the set into 3,222 inline links in running prose and 1,977 standalone controls.
One number, 621 times
Of the 1,977 standalone targets, 621 are under 24 pixels in one dimension. That is 31.4 per cent, which was roughly what I feared.
Then I looked at the distribution of their heights:
| Height | Count |
|---|---|
| 20px | 621 |
Every single one. Not a spread from 14 to 23, not a couple of outliers. One value, 621 times, because they are all the same thing: a 14px text link with a line height of 20 and no vertical padding. Widths vary from 27.5px to 273.9px. Heights do not vary at all.
They come in three groups:
- 487 content links. The
text-link underline-offset-4style, used in headings, in captions and in list items that are not inside ap. - 126 navigation links. The breadcrumb "Home" on 71 pages, the contents link "Questions" on 64, the hub links "Guides" and "YouTubers by niche" on the rest.
- 8 buttons, all on the home page, all part of the walkthrough that steps through an example entry: three "Next" buttons at 27.5 by 20, a "Done" at 31.7 by 20, and four help triggers.
The healthy part of the same distribution is a tidy ladder, which is what you get when sizes come from one component rather than from whatever felt right: 348 targets at 36px, 254 at 32, 191 at 80, 178 at 144, 145 at 56, 112 at 168, 76 at 48.
Exactly two of the 76 pages have no undersized standalone target at all: the pricing page and the about page. Both are hand-built pages of cards and buttons with no breadcrumb. The worst page is the glossary with 28, because its 27 definitions cross-link to each other and those links sit in their own div rather than in a sentence.
The exemption does all the work
2.5.8 has an escape hatch, and it is a good one. An undersized target passes anyway if a 24px diameter circle centred on it does not intersect the circle of any other target. Spacing substitutes for size, which is the right rule, because what actually makes a small target hard to hit is a neighbour you might hit instead.
That is a computation, not a judgement, so I ran it: for each of the 621 undersized targets, is any other target's centre within 24 pixels?
Zero failures. All 621 pass.
So the site meets 2.5.8 at level AA, and it meets it entirely by accident of layout. Nobody sized those links to 24. They are 20 tall and they sit in generous space, and the generous space is why they are compliant. If one day a designer tightens a line height or puts two of those links side by side, the criterion fails and nothing in the codebase will notice. That is the real finding here: the pass is not held up by anything a test or a component enforces.
Against the other two common bars, the site does worse. Apple's guidance is 44 by 44, Material's is 48 by 48. 1,243 of the 1,977 standalone targets are under 44, which is 62.9 per cent, and 1,245 are under 48. The biggest contributor is the button component itself, whose default size is h-9:
size: { default: "h-9 gap-1.5 px-3.5 ...", xs: "h-7 gap-1 px-2 text-xs ...", sm: "h-8 gap-1.5 px-3 text-[13px] ...", lg: "h-11 gap-2 px-5 text-[15px]", icon: "size-9", } 36 pixels. Comfortably over the WCAG minimum, comfortably under Apple's. 412 button instances and 35 text inputs sit at that height. There is an lg at h-11, which is 44 exactly, and it is used for the places that matter most, but it is not the default.
And there is no pointer: coarse media query anywhere in 154 KB of CSS. The site makes no distinction between a mouse and a thumb. That is a defensible choice, since the same 36px button is fine with a cursor, but it is a choice by omission rather than one anybody made.
The blind spot in my own measurement
Two components in the app already solve this properly, and my audit cannot see it.
The checkbox is 16 pixels square, which would be a flagrant 2.5.8 failure if the box were the target. It is not:
"peer relative flex size-4 shrink-0 items-center justify-center rounded-sm border border-input bg-card ... after:absolute after:-inset-x-3 after:-inset-y-2 ..." An absolutely positioned ::after with negative insets, 12px to each side and 8px above and below. The real hit area is 40 by 32. The switch, which is 18.4 by 32, carries the same two utilities.
getBoundingClientRect() returns the element's box. It knows nothing about a pseudo-element that catches the pointer. So an audit like mine understates those two components badly, and would understate any component that pads its hit area that way.
The method that does see it is document.elementFromPoint, which asks the browser the question a finger asks. Here it is on the glossary breadcrumb, probing out from the centre of a 38 by 20 link:
const link = document.querySelector("a[href]"); // pick a 20px one const b = link.getBoundingClientRect(); const probe = (dy) => document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2 + dy); probe(0); // the link probe(-6); // the link probe(-12); // DIV, so nothing is listening up there probe(6); // the link probe(12); // DIV Hits at six pixels out, misses at twelve. No hidden padding, so for that link the box really is the target. Run the same probe on a checkbox in the app and it keeps hitting the control well outside its box.
The lesson I am taking from it: a rect-based target audit is a screening tool, not a verdict. Everything it flags needs one elementFromPoint check before you believe it.
What I am changing
- Pad the navigation links. The breadcrumb and the contents list are not sentences, and
py-1.5takes them from 20 to 32 without moving anything else. That removes 126 of the 621 and, more to the point, removes them from depending on the spacing exemption. - The walkthrough buttons on the home page should be real buttons. Four controls at 20px tall in a component people are meant to tap through is the one genuinely poor result in this sweep.
- Leave the 487 prose links alone. They are exempt, they are exempt for a good reason, and padding them would break the line rhythm of every guide on the site.
- Add a test that fails on a 20px standalone target. The pass is currently luck. A check in the existing page-level test suite that walks the rendered output and flags a non-inline target under 24 would make it a decision.
All of it is live. Open the glossary or a guide on a phone, or at 390px in DevTools, and you can run the sweep yourself:
[...document.querySelectorAll('a[href], button, input, summary')] .map((el) => ({ el, b: el.getBoundingClientRect() })) .filter(({ b }) => b.height > 0 && (b.width < 24 || b.height < 24)) .length; The number you get will be larger than mine, because that one-liner does not exempt the prose links. Mine took the exemptions seriously, which is the only way the result means anything.