Nakodo has 76 public URLs, all of them in sitemap.xml. None of them are generated by a page builder: they are React components and a content registry, which means every accessibility decision in them was made by somebody once and then copied 75 times, for better or worse.
So I measured it rather than guessing. The method is three lines and you can run it against the same pages:
curl -s https://nakodo.app/sitemap.xml | grep -o '<loc>[^<]*</loc>' | sed 's|</*loc>||g' > urls.txt
i=0; while read u; do i=$((i+1)); curl -s --compressed "$u" -o "page-$i.html"; done < urls.txt
grep -c '<h1' page-*.html
36 MB of HTML. Here is what it says.
The parts that hold on all 76
- Exactly one
<h1>, one<main>, one<header>and one<footer>per page. Minimum one, maximum one, on every single URL. -
<html lang="en">on every page. - Zero
<img>elements. All the artwork is inline SVG, 2,657 of them across the 76 pages, between 25 and 78 per page. - Of those 2,657, 2,581 carry
aria-hidden="true". The remaining 76 are one per page, and each one is an<svg>nested inside another<svg>that is already hidden, so they inherit it. That is a decorative illustration system that never has to be explained to a screen reader, and the count of exceptions is not "76 mistakes", it is one pattern. - 81
tabindexattributes, and every single one of them istabindex="-1". No positive tabindex anywhere, which is the only thing you can really ask of a tabindex sweep. - 527
aria-labelledbyattributes, nearly all of them a<section>pointing at its own<h2>:
<section aria-labelledby="who" class="space-y-3">
<h2 id="who" class="text-[26px] leading-8">Who builds it</h2>
- Heading levels: 76
h1, 576h2, 10h3, nothing deeper, and only one page where the order jumps. - One focus rule for the whole site, in the live CSS, rather than per component:
:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
Finding 1: there is no skip link
5,184 links across the 76 pages, between 47 and 117 per page, and the header and its navigation come before <main> in every one of them. A keyboard user on a niche page starts a long way from the content.
Grepping for one is almost a joke:
$ grep -l 'href="#main' page-*.html # nothing
$ grep -l 'href="#content' page-*.html
page-13.html
Page 13 is /terms, and the match is clause 8, "Your content", in the contents list at the top. The only #content link on the site is a legal clause about user content.
The CSS confirms it: 53 mentions of focus-visible, three of sr-only, and zero occurrences of the string skip in 154 KB of stylesheet. This is the first thing I will fix, and it is about six lines: one anchor at the top of the body, sr-only until focused, pointing at the <main> that already exists on every page.
Finding 2: 290 nav landmarks, 76 of them unnamed
Per page there are two to four <nav> elements. Counted across the site:
76 <nav aria-label="Footer">
71 <nav aria-label="Breadcrumb">
66 <nav aria-label="Contents">
1 <nav aria-label="Terms">
76 <nav> <- no label
The unnamed one is the header's, on every page. A screen reader's landmark list therefore reads "navigation, navigation, Breadcrumb, Contents, Footer", with the most important one of the five the only one that cannot be told apart. The fix is one attribute.
The breadcrumb numbers are consistent, at least: 71 pages have one, 71 pages have exactly one aria-current="page" in it, and the five without are the five pages with nothing to be a breadcrumb for: the home page, pricing, about, privacy and terms.
Finding 3: aria-expanded 76 times, aria-controls zero times
Every page has exactly one aria-expanded, on the mobile menu button, and not a single aria-controls anywhere on the site. The button announces that it is collapsed without saying what it would expand. That is a smaller problem than it sounds (the menu follows the button in the DOM) and still a one-attribute fix.
Finding 4: every marketing page ships a toast region
This is on all 76 public pages, right before the closing scripts:
<section aria-label="Notifications alt+T" tabindex="-1" aria-live="polite"
aria-relevant="additions text" aria-atomic="false"
data-react-aria-top-layer="true"></section>
It comes from <Toaster position="bottom-right" />, which lives in the root layout, which wraps the signed-in app and the public site alike. Inside the app it is right: actions report their result as a toast, and a polite live region is how that reaches a screen reader.
On a guide page it is a region landmark named after a feature that cannot happen, and the name includes a keyboard shortcut (alt+T) that focuses a toast list which will always be empty. Nothing is broken; a landmark and a hotkey are being advertised to the people most likely to go looking for them.
The fix is to move the <Toaster> out of the root layout and into the app layout, which exists already, because the marketing pages have no action that can produce a toast. It also takes a client component off every public page, which pays twice.
Finding 5: an aria-label doing nothing, 152 times
The wordmark, header and footer, every page:
<a class="flex shrink-0 rounded-sm" aria-label="Nakodo home" href="/">
<svg viewBox="-0.3 -0.3 15.6 15.6" class="size-8 shrink-0" aria-hidden="true">...</svg>
<span class="relative inline-block font-display ..." aria-label="Nakodo">
<span aria-hidden="true">nakodo<span class="... rounded-full ..."></span></span>
</span>
</a>
The visible text is hidden from assistive technology, presumably because the dot in the logotype is a <span> inside the word and would be read as a gap in it, and the replacement name is an aria-label on a plain <span>. A <span> has no role, and an aria-label on a generic element with no role is not exposed by most screen readers: the name is dropped.
Here it does not matter, because the enclosing link carries aria-label="Nakodo home" and that is what gets read. It matters the first time the wordmark is used outside a link. The honest fix is a single sr-only span with the text in it, which works everywhere and needs no knowledge of ARIA's name-from-author rules.
Finding 6: one heading jump, on the page that sells things
/pricing goes h1 then h3:
<h3 class="font-sans text-lg font-semibold">Free</h3>
The plan cards are h3 because they are visually smaller than a section heading, and there is no h2 above them because the plans are the page. Either the cards become h2, or they get a section heading that names them. Font size is not a heading level, and this is the one place on the site where the two got confused.
Three things it got right that I did not expect
The footer's etymology line is marked up with a language change, on all 76 pages, and twice on /about:
<p><span lang="ja">仲人</span>, nakōdo: Japanese for the go-between who makes the introduction.</p>
A sparkline on the home page is a row of <span> bars with a role and a sentence rather than a chart library:
<span class="flex h-6 items-end gap-1" role="img" aria-label="Uploads in each of the last 12 weeks">
And the only other role attributes on the entire site are a radiogroup with three radio children, on the sponsorship cost calculator. Ten role attributes across 76 pages, which is roughly the right number: native elements do the rest.
The order I will fix them in
- The skip link, because it is six lines and it affects every page.
-
aria-labelon the header<nav>, because it is one attribute and it fixes 76 landmark lists. - The
<Toaster>moving into the app layout, because it removes a false landmark and a chunk of JavaScript at the same time. - The wordmark's
sr-onlyspan. -
/pricingheadings. -
aria-controlson the menu button.
None of that is a redesign. It is an afternoon, found by curling 76 URLs and counting. The reason to write the method down is that it is repeatable: the same three commands, run against your own sitemap, will tell you the same kind of truth about your own pages.
Top comments (0)