How to add an accessible skip link and ARIA-correct navigation
Not everyone uses a mouse. Keyboard and screen-reader users move through a page with Tab, and if the first thing on every page is a block of navigation, they have to tab through all of it every single time before reaching the content. A skip link fixes that in one keystroke. My portfolio has one, plus a nav marked up so assistive tech announces it correctly. Here’s how both work.
What a skip link is
A skip link is a hidden link that becomes visible only when it’s focused (i.e. the very first time you press Tab). Activating it jumps the keyboard focus straight to the main content, past the navigation. It’s the first focusable element on the page, so it’s the first thing Tab lands on.
Here’s mine — the literal first element inside the app:
// src/App.tsx
<div className="scanlines">
{/* Skip link — first focusable element for keyboard/screen-reader users */}
<a href="#main-content" className="skip-link">
Skip to content
</a>The href="#main-content" points at the id of my main content region. Press Tab on a fresh page, the link appears, press Enter, and focus lands on the content — the nav is skipped entirely.
The visually-hidden-until-focus pattern
The important part is how it hides. A skip link must be in the DOM and focusable, but invisible until needed. The wrong way is display: none or visibility: hidden — those remove the element from the tab order, so keyboard users can never reach it. (This is the same reason the site uses sr-only rather than display: none when mirroring tabbed content for crawlers and screen readers.)
The right way is to keep it rendered and just push it off-screen, then bring it back on :focus. Here’s the actual CSS:
/* src/index.css */
/* Visually hidden until focused, then pinned top-left over everything. */
.skip-link {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
transform: translateY(-120%); /* pushed up out of view */
padding: 0.6rem 1rem;
background: oklch(0.145 0 0);
color: oklch(0.98 0 0);
border: 1px solid oklch(0.5 0 0);
border-radius: 0 0 0.375rem 0;
font-family: var(--font-mono, monospace);
font-size: 0.8125rem;
transition: transform 0.15s ease-in-out;
}
.skip-link:focus {
transform: translateY(0); /* slides into view */
outline: 2px solid oklch(0.7 0 0);
outline-offset: 2px;
}transform: translateY(-120%) shifts the link fully above the top edge of the screen — it’s still in the DOM and still focusable, just not visible. On :focus, translateY(0) slides it back to top: 0, and the transition gives it a smooth slide. A high z-index keeps it above everything, and the visible outline makes the focused state obvious.
The reason transform is used rather than moving it with top: -100px is that transforms are cheap for the browser to animate and don’t affect layout.
Marking up the navigation for assistive tech
A skip link only helps if the nav it skips is itself understandable. My section navigation is a set of dots, and dots have no text — so a screen reader would announce “button” with nothing else. ARIA attributes fill that gap:
// src/App.tsx
<nav aria-label="Section navigation">
{SECTIONS.map((sec, i) => (
<button
key={sec.id}
onClick={() => goTo(i)}
title={sec.label}
aria-label={`Go to ${sec.label} section`}
aria-current={current === i ? "true" : undefined}
className={`nav-dot ${current === i ? "active" : ""}`}
/>
))}
</nav>Three things make this accessible:
aria-labelon the<nav>(“Section navigation”) gives the whole landmark a name. Screen-reader users can list landmarks and jump between them; naming it means it’s not just an anonymous “navigation.”aria-labelon each button turns a wordless dot into a real label: “Go to Skills section.” Without it, the button announces nothing meaningful.aria-currenton the active dot. This is the key one for state. When a dot is the current section it getsaria-current="true"; otherwise the attribute is left off entirely (undefined), not set to"false". Screen readers announce the current item as “current,” so the user knows where they are — the same information the visualactivehighlight conveys to sighted users.
Setting aria-current to undefined rather than "false" is deliberate: an absent attribute is the correct way to say “not current,” and it keeps the announcement clean.
What to remember
- Make the skip link the first focusable element and point it at your main content’s id.
- Hide it with
transform: translateY(-120%), reveal it on:focus— neverdisplay:none, which drops it from the tab order. - Give the
<nav>anaria-labelso it’s a named landmark. - Icon-only controls (like nav dots) need an
aria-labeldescribing the action. - Use
aria-currentto expose the active item to screen readers, and omit it (undefined) rather than setting"false"when it’s not current.
For more on making keyboard users first-class, see adding keyboard navigation that doesn’t hijack form inputs.