The cheapest mobile test there is: set the viewport to 320 CSS pixels, load the page, and compare documentElement.scrollWidth with clientWidth. If the first is bigger, something sticks out and the whole page rocks sideways under a thumb.

I ran it over all 76 public pages of Nakodo:

await page.cdp("Emulation.setDeviceMetricsOverride", { width: 320, height: 640, deviceScaleFactor: 2, mobile: true }); // per page: document.documentElement.scrollWidth // 320 on all 76 document.documentElement.clientWidth // 320 on all 76 

No page scrolls sideways. I was pleased with that for about forty seconds, which is how long it took to run the second measurement.

1,308 elements are over the right edge anyway

for (const el of document.querySelectorAll("body *")) { const b = el.getBoundingClientRect(); if (b.width === 0 || b.height === 0) continue; if (b.right > innerWidth + 1 || b.left < -1) over.push(el); } 

Across the 76 pages that collects 1,308 elements whose box extends past the viewport. Both measurements are correct, which tells you the naive check is measuring something narrower than "does anything stick out". An element can be outside the viewport and cost nothing, as long as some ancestor is clipping or scrolling it.

So the real question is which ancestor, and what that ancestor does about it:

const clipAncestor = (el) => { let n = el.parentElement; while (n && n !== document.body) { const ox = getComputedStyle(n).overflowX; if (ox !== "visible") return { tag: n.tagName.toLowerCase(), ox, scrollable: n.scrollWidth > n.clientWidth + 1 }; n = n.parentElement; } return getComputedStyle(document.body).overflowX !== "visible" ? { tag: "body" } : null; }; 

Three buckets, and the counts over 76 pages:

in a scrollable ancestor 1,128 fine, you can swipe to it clipped by a hidden one 180 invisible, deliberate no clipping ancestor 0 this is the bucket that would be a bug text clipped away 0 

Nothing on the site is cut off, and nothing is hidden by the trick I was half expecting to find, which is overflow-x: hidden on the body. Both html and body compute to overflow-x: visible on every page, so that zero in the third bucket is real rather than suppressed. If you are running this on your own site, grep for that rule first: a body with hidden overflow turns "the layout is broken" into "the layout is fine and some content is gone", and the scrollWidth check cannot tell the difference.

The 180 are all one page, and they are all inside an SVG

Every clipped element is on the home page, and the classifier says the clipping ancestor is an svg every time.

That is the user agent stylesheet doing it, not me. An svg element clips to its viewport by default, so line art drawn in a 96 unit grid can run off the edge of its own box and the page never notices. Our landing page is 596 KB of HTML with no images in it, because all the art is inline SVG, and the footer is a drawn landscape with hills that are wider than any phone. Those hills are 180 of the paths and groups in that count. They have never been a layout problem, and until I ran this I could not have told you why not.

The 1,128 are 52 tables, and they are identical

pages with a horizontal scroller 46 of 76 scrollers 52 all of them containing a table 52 distinct class attributes 1 scrollWidth of every single one 560 clientWidth of every single one 320 

One class, one width, no exceptions:

case "table": return ( <div className="-mx-5 overflow-x-auto px-5 sm:mx-0 sm:px-0"> <table className="w-full min-w-[520px] text-left text-sm"> 

Two deliberate choices in one line. min-w-[520px] says a table with four columns of prose is unreadable below that width, so do not try: let it be wider than the phone and let the reader move it. And -mx-5 ... px-5 sm:mx-0 sm:px-0 is the full bleed trick: pull the scroll container out to the screen edges with negative margin, put the same amount back as padding so the first column does not start under the bezel, then cancel both at the sm breakpoint where the table fits anyway. The 560 is that arithmetic, 520 plus 20 of padding on each side, which is why every one of the 52 is the same number regardless of what is in it.

I like this component more having measured it. It is the one place in the whole site where the narrow layout is a different layout rather than a squeezed one.

The part I did not like

scrollers with tabindex 0 of 52 scrollers with a role 0 of 52 scrollers with an aria-label 0 of 52 scrollers containing a link 9 of 52 

A scrollable box that is not focusable and has nothing focusable inside it cannot be scrolled by somebody who is not using a pointer. That is axe-core's scrollable-region-focusable rule and it is a WCAG 2.1.1 failure, and on 43 of our 52 tables the right-hand columns are the content it loses.

Then I tabbed through one of them to see how bad it actually is, at 320px, on the engagement rate guide:

tabs until document.activeElement is the scroller: 15 activeElement.className: "-mx-5 overflow-x-auto px-5 sm:mx-0 sm:px-0" activeElement.getAttribute("tabindex"): null computed outline on it: 2px solid (our focus ring, unchanged) 

Chrome focuses it anyway. Keyboard focusable scrollers makes a scroll container a tab stop when it has no focusable children of its own, which is exactly our case, and Firefox has treated scrolling areas as tab stops for far longer than that. Safari does not: the WebKit bug is open and the workaround in it is the same tabindex="0" I had not written.

So the shape of the defect is not "nobody can read these tables". It is "two engines quietly fixed this for us and one did not", which is a worse thing to be relying on than a bug, because it works on the browser I test in.

There is a second half to it that the browsers cannot fix. I asked Chrome what that focused element is called:

{"role":"generic","name":"Hypothetical numbers for one channel","props":["focused=true"]} {"role":"generic","name":""} 

Two scrollers on the page. The first borrows its name from the table's <caption>. The second has no name at all, because only 12 of the 52 have a caption, so a screen reader user on the other 40 gets a focus stop that announces a role of nothing and a name of nothing, and then has to work out from context that arrow keys now move a table.

The fix is three attributes rather than one: tabindex="0" so Safari agrees, role="region" so the stop is announced as something, and a name, which for twelve of the tables is already sitting in the caption and for the rest has to be written. Adding tabindex="0" does not cost a double tab stop in Chrome or Firefox, because their heuristic only applies to scrollers that are not already focusable.

The breakpoint census, which is the same story from the other end

While I had the numbers out, I counted which Tailwind breakpoints the app actually uses:

sm: 358 md: 49 lg: 134 xl: 5 2xl: 0 

Five breakpoints available, two in real use. sm (640px) is where a phone layout becomes a small-screen layout, lg (1024px) is where sidebars and two-column splits arrive, and md is mostly accidental. Nothing in the app distinguishes a large desktop from a very large one.

That distribution is why the 320px sweep came back as clean as it did. Almost every component is written at its narrow size first and then told what to do at sm, so the narrow case is the default rather than the edge case, and a layout that is only ever tested wide is the one that produces a 400px-wide element in a 320px viewport.

The exception is the tables, which cannot be made narrow without being made useless, and which are therefore also the only place a keyboard user can get stuck. The two facts are the same fact. The component that refuses to fit is the component that needs an escape hatch, and an escape hatch is a thing you have to build for every input device, not just the one in your hand.

Run it on your own site: 320 wide, compare scrollWidth to clientWidth, then walk up from every box that pokes out and ask what is clipping it. Both numbers are cheap, and the second one is the interesting one.

Sources for the browser behaviour: Keyboard focusable scrollers on developer.chrome.com, Adrian Roselli on keyboard-only scrolling areas, WebKit bug 277290, axe rule: scrollable region must have keyboard access.