Nakodo has two halves: 76 public pages that anybody can read, and an app behind a login where the campaigns live. Nobody ever sat down and designed "the navigation". Each area grew its own as it was needed, and the result is sixteen <nav> elements written by hand across thirteen files.
I counted them this week. The numbers are the interesting part, and so is the disagreement.
The census
$ grep -ro '<nav' src | wc -l
16
$ grep -rn '<nav' -A 2 src | grep -c 'aria-label'
10
$ grep -rn 'aria-current' src | wc -l
9
Sixteen landmarks, ten with an accessible name, nine places that mark something as current. The four values in use are page (six times), step (once), true (once), and one hardcoded page on a <span> that is not a link at all.
The public half of that is checkable from outside, which is the nice thing about having a site rather than only an app:
$ for p in / /pricing /guides /guides/how-to-find-youtube-influencers /terms /glossary; do
html=$(curl -s "https://nakodo.app$p")
printf '%-44s navs=%s labels=[%s] current=%s\n' "$p" \
"$(grep -o '<nav' <<<"$html" | wc -l | tr -d ' ')" \
"$(grep -o '<nav[^>]*aria-label="[^"]*"' <<<"$html" | sed 's/.*aria-label="//; s/"//' | paste -sd, -)" \
"$(grep -c 'aria-current' <<<"$html")"
done
/ navs=2 labels=[Footer] current=0
/pricing navs=2 labels=[Footer] current=0
/guides navs=3 labels=[Breadcrumb,Footer] current=1
/guides/how-to-find-youtube-influencers navs=4 labels=[Breadcrumb,Contents,Footer] current=1
/terms navs=3 labels=[Contents,Footer] current=0
/glossary navs=4 labels=[Breadcrumb,Terms,Footer] current=1
Every public page has exactly one unlabelled landmark, and it is always the same one: the header. I published that finding a few days ago after grepping all 76 pages, so it is not news, but the table shows the other half of it. The only aria-current a public page ever emits is the last breadcrumb, which means the home page, pricing and the terms emit none at all, because none of them has a breadcrumb trail. On a guide there are four landmarks and one marker.
Four answers to one question
Behind the login, five link rows each decide which of their links is the current one, and no two of them do it the same way.
The app header has a helper:
const isActive = (path: string, href: string) => path === href || path.startsWith(`${href}/`);
The settings nav has the same expression, inlined, not imported:
const active = path === l.href || path.startsWith(`${l.href}/`);
The admin nav has a special case for its own root and then drops the slash:
const active = l.href === "/admin" ? path === "/admin" : path.startsWith(l.href);
The campaign tab bar writes a predicate per tab, and one of them claims a route that is not under it:
const tabs = [
{ href: base, label: results, active: path === base || path.startsWith(`${base}/creators`) },
{ href: `${base}/outreach`, label: "Outreach", active: path.startsWith(`${base}/outreach`) },
{ href: `${base}/brief`, label: "Brief", active: path.startsWith(`${base}/brief`) },
];
And the conversations row does not read the path at all, because its views are a query parameter:
<Link href={href({ view: v.key })} aria-current={view === v.key ? "page" : undefined} scroll={false}>
Three of those four are fine. The admin one is the one with a real edge: startsWith("/admin/users") would also light up for /admin/users-archive, a route that does not exist today and would be a plausible thing to add. The campaign tab bar is the interesting one in the other direction: a creator's own page lives at /campaigns/<id>/creators/<channelId>, which is not under the results tab's href, so the tab bar has to be told that the detail page belongs to the list it came from. That is a product decision wearing a boolean's clothes, and the only honest place to put it is in the tab definition.
The lesson I took is not "extract a helper". It is that "is this link current" has four different correct answers in one app, and a single shared function would have made four call sites lie slightly rather than one of them be explicit.
The phone does not say it
The desktop header marks the current page for assistive technology. The mobile menu, which replaces it below the md breakpoint, does not:
<nav className="flex flex-col gap-1 px-4">
{links.map((l) => (
<Link
key={l.href}
href={l.href}
onClick={() => setOpen(false)}
className={cn("rounded-md px-3 py-2 text-sm",
isActive(path, l.href) ? "bg-selected font-medium" : "text-muted-foreground hover:bg-muted")}
>
Same helper, same truth, and the answer is spent entirely on a background colour. On a phone the current page is visible and unannounced. That is one attribute, and it is the first thing on the list below.
The nav with no current page
One nav deliberately has no notion of current, because every item in it is somewhere else. The creator page's nav is back, previous and next through the list you arrived from, and it also binds two keys:
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey || e.defaultPrevented) return;
const t = e.target as HTMLElement | null;
if (t && (t.isContentEditable || t.closest("input, textarea, select, [role='dialog'], [role='listbox']"))) return;
if (e.key === "j" && next) router.push(next.href);
else if (e.key === "k" && prev) router.push(prev.href);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [router, prev, next]);
J and K step through the filtered list, as in most mail apps. Three quarters of that handler is about not stealing a keystroke: modifier chords belong to the browser, defaultPrevented belongs to whatever handled it first, and a bare letter belongs to any field, dialog or listbox the person is actually typing into. A global keydown that does not check those is how an app ends up navigating away mid-sentence.
The nav itself takes its accessible name from the campaign's own vocabulary, so a screen reader hears "Creators in this list" or "Businesses in this list" rather than the fourth unnamed navigation on the page.
Two near-identical twins, on purpose
The settings nav and the admin nav are 40 and 42 lines of nearly the same code: a left column on desktop, a scrolling row of tabs on mobile, the same inset shadow under them instead of a border. The admin one says so in a comment:
// A left column on desktop, a scrolling row of tabs on mobile, matching the
// settings nav so the two private areas of the app feel like one tool.
I have twice started to merge them and twice stopped. The link lists differ, the active rule differs (see above), and one of them is behind an allowlist that answers 404 to everybody else. What they share is 20 lines of Tailwind, and sharing those would mean a component with a links prop, an active-rule prop and a layout prop, which is a worse artefact than the duplicate. The underline that covers the rule rather than sitting next to it is the one piece of it I did write up separately, because it was a bug first.
Three things to fix
- The mobile menu should pass
aria-currentlike its desktop twin. One attribute. - Four of the sixteen landmarks have no accessible name: the public header, the app header, the settings nav and the admin nav. On pages that carry three or four navs, "navigation" repeated is not a list a screen reader user can use.
- One
aria-current="true"in the reply review screen should be a token.trueis valid and means "this one", butpageorsteptells the user what kind of sequence they are in, and this one is a sequence.
None of that is architecture. It is the cost of letting navigation grow per area instead of designing it once, and the bill is four attributes and a label.
Top comments (0)