Skip to content

feat(a11y): focus states, skip link and landmark names for header/nav (closes #14) - #17

Open
dchaudhari7177 wants to merge 1 commit into
Lilly-Protocol:mainfrom
dchaudhari7177:feat/header-nav-accessibility
Open

feat(a11y): focus states, skip link and landmark names for header/nav (closes #14)#17
dchaudhari7177 wants to merge 1 commit into
Lilly-Protocol:mainfrom
dchaudhari7177:feat/header-nav-accessibility

Conversation

@dchaudhari7177

Copy link
Copy Markdown

Closes #14.

The scaffold styled :hover and nothing else, so tabbing through the header was invisible — the links were reachable, you just couldn't tell where you were. That's the actual barrier; the links themselves were already real anchors.

Changes

File Change
globals.css One :focus-visible ring for every interactive control
site-header.tsx Skip link, aria-label="Primary" on the nav, accessible name on the wordmark
page-scaffold.tsx <main id="main-content" tabIndex={-1}>

Focus ring is central, not per-component. A :where(a, button, input, …):focus-visible rule means the next contributor doesn't have to remember to add one. :focus-visible rather than :focus so a mouse click leaves no ring behind, and a 2px offset so the ring clears the pill links' own border — on those, the border is the edge, and a flush ring reads as a thicker border rather than as focus.

The skip link is the first focusable element and is hidden until focused (WCAG 2.4.1, Bypass Blocks). It's a plain <a>, deliberately not next/link: a same-page fragment shouldn't go through the router.

tabIndex={-1} on <main> is the part that's easy to miss — without it the browser scrolls to the target but leaves focus in the header, so the next Tab drops you back into the nav you just skipped. main:focus { outline: none } because focusing a landmark moves the reading position; it isn't a control to highlight.

aria-label="Primary", not "Global"SectionNav (aria-label="Section routes") is on the same page, and "Global" doesn't distinguish the two in a landmark list.

No visual change for mouse users, and nothing about the Figma layout moves.

Tests

6 in site-header.test.tsx: banner landmark, named navigation landmark, the wordmark's accessible name, the skip link being first in tab order and pointing at #main-content, the complete tab order through every primary link, and every nav destination being a real href rather than a click handler.

Verified they can fail — removing the skip link and the nav label fails 4 of the 6:

× names the primary navigation landmark
× offers a skip link as the first focusable element
× reaches every primary link by keyboard, in visual order
× keeps every nav destination a real link rather than a click handler
Tests  4 failed | 2 passed (6)

Gate

The full check script, all four steps:

  • npm run lint — clean
  • npm run typecheck (next typegen && tsc --noEmit) — clean
  • npm run test:run9 → 15 passed, statements 96.29%
  • npm run build — succeeds

Per AGENTS.md I read the bundled node_modules/next/dist/docs (Next 16.2.6) before writing: the Link reference confirms <Link> renders an <a> and that hash hrefs are handled, which is what settled the skip link staying a plain anchor.

Not covered

Contrast ratios against the Figma palette. The ring uses --color-accent, so if #15 retunes the brand tokens the focus ring follows automatically — but someone with Figma access should sanity-check the ring against the final dark palette.

🤖 Generated with Claude Code

The scaffold styled :hover and nothing else, so tabbing through the
header was invisible - the links were reachable but you could not tell
where you were. That is the actual barrier here; the links themselves
were already real anchors.

- globals.css: one :focus-visible ring for every interactive control,
  rather than each component remembering its own. :focus-visible not
  :focus, so a mouse click leaves no ring, and a 2px offset so the ring
  clears the pill links' own border.
- site-header.tsx: a skip link as the first focusable element, hidden
  until focused (WCAG 2.4.1). Kept as a plain <a>, not next/link - a
  same-page fragment should not go through the router.
- page-scaffold.tsx: <main id="main-content" tabIndex={-1}> so the skip
  link lands somewhere and moves focus, not just scroll position.
- The header nav is aria-label="Primary" rather than "Global", since
  SectionNav is also on the page and "Global" does not distinguish them.
- The wordmark gets an accessible name saying it goes home; its visible
  text is only the site name.
- main:focus outline is suppressed: focusing a landmark moves the
  reading position, it is not a control to highlight.

No visual change for mouse users; nothing about the Figma layout moves.

Tests: 6 in site-header.test.tsx - banner landmark, named nav landmark,
the wordmark's accessible name, the skip link being first in tab order
and pointing at #main-content, the full tab order through every primary
link, and every nav destination being a real href.

Verified they can fail: removing the skip link and the nav label fails 4
of the 6.

Gate: npm run lint, npm run typecheck, npm run test:run (15 passed, 9
before) and npm run build all pass - i.e. the full `check` script.

Read node_modules/next/dist/docs per AGENTS.md before changing anything;
the Link reference confirms <Link> renders an <a> and that hash hrefs
are handled, which is why the skip link stays a plain anchor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

@dchaudhari7177 is attempting to deploy a commit to the Heymide Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant