Accessible Breadcrumbs & In-Page Anchors
Breadcrumbs and on-this-page links answer the same question — where am I, and what is near me? — at two different scales. Both are small components, and both have a failure mode that is invisible in a visual review: the breadcrumb that never says which entry is the current page, and the anchor link that scrolls the viewport without moving focus. This guide covers the markup for each, the focus behaviour fragment links need, and the sticky-header problem that silently breaks both.
It belongs to Accessible Navigation & Landmark Structure, which covers the surrounding landmark skeleton.
WCAG Success Criteria Addressed:
2.4.4 Link Purpose (In Context)2.4.5 Multiple Ways2.4.8 Location2.4.11 Focus Not Obscured (Minimum)1.3.1 Info and Relationships
Prerequisites
- A route hierarchy you can derive a trail from — a content collection, a router, or a static map of parents.
- A page that already has one
<h1>and a sequential heading outline; the on-this-page list is generated from it. - Somewhere to hang a sticky header offset in CSS, if the layout uses one.
Breadcrumbs: an Ordered List Inside a Labelled Nav
The markup is small and every part of it does a job. The <nav> makes the trail reachable from the landmark list; the label distinguishes it from the site navigation; the <ol> encodes that the order is meaningful; aria-current names the endpoint.
Implementation Guidelines:
- Wrap the trail in
<nav aria-label="Breadcrumb">. The label is what stops the rotor from listing two identical "navigation" entries. - Use an
<ol>, not a<ul>or a row of<div>s — the hierarchy is an ordering, and screen readers announce the position and count. - Render the current page as the last item, but not as a link. Mark it
aria-current="page". - Draw separators with CSS (
::aftercontent or a border) so they are never announced. A literal>between links is read aloud on some configurations.
export function BreadcrumbTrail({ crumbs }: { crumbs: Crumb[] }) {
return (
<nav aria-label="Breadcrumb" className="breadcrumb">
<ol>
{crumbs.map((crumb, index) => {
const isCurrent = index === crumbs.length - 1;
return (
<li key={crumb.href}>
{isCurrent ? (
// A11y rationale: 2.4.8 Location — the endpoint is announced as
// the current page rather than offered as a link to itself.
<span aria-current="page">{crumb.label}</span>
) : (
<a href={crumb.href}>{crumb.label}</a>
)}
</li>
);
})}
</ol>
</nav>
);
}
/* Separator as presentation, invisible to assistive technology. */
.breadcrumb li + li::before {
content: '›';
margin: 0 0.5rem;
color: var(--muted);
}
Keep the labels short but self-describing. 2.4.4 Link Purpose (In Context) is satisfied by the surrounding list, but a trail reading "Home › Section › Page" tells nobody anything — use the real titles, truncating in the middle with CSS rather than shortening the text itself.
Structured Data Without Breaking the Markup
Search engines read breadcrumbs from BreadcrumbList structured data. That data is metadata, not content, so it should never change the accessible markup.
Implementation Guidelines:
- Emit
BreadcrumbListJSON-LD in a<script type="application/ld+json">tag rather than sprinklingitempropattributes through the list. - Keep the JSON in sync with the rendered trail by generating both from the same array.
- Include the current page in the JSON-LD (with its position) even though it is not a link in the markup.
- Never add
aria-labeltext purely for search engines. The accessible name is a user-facing string.
In-Page Anchors Need a Focusable Target
A fragment link (href="#section-id") moves the viewport. Whether it moves focus depends on whether the target can hold focus — and headings cannot by default. The result is the same class of bug as a broken skip link: the page scrolls, the screen reader keeps reading where it was, and the next Tab continues from the top.
Implementation Guidelines:
- Add
tabindex="-1"to every heading that is a fragment target. It accepts programmatic focus without joining the tab order. - Set
scroll-margin-topon those headings to the height of any sticky header, so the target is not covered when it scrolls into view. - Suppress the focus ring on the heading only if the scroll itself is obvious; many teams keep it, and it is the clearest signal that the jump worked.
- Generate ids deterministically from the heading text and keep them stable — a slug that changes when a typo is fixed breaks every existing deep link.
// Generated table of contents; targets are the headings themselves.
<nav aria-label="On this page">
<ul>
{toc.map((entry) => (
<li key={entry.id}>
<a href={`#${entry.id}`}>{entry.text}</a>
</li>
))}
</ul>
</nav>
/* Fragment targets are focusable and clear of the sticky header. */
.content-prose h2[id],
.content-prose h3[id] {
scroll-margin-top: 98px;
}
In a single-page app, one more step is needed: a fragment link that changes the URL without a full navigation may not move focus at all, because the browser only applies fragment focus on load. Handle it in the router's after-navigation hook by finding the element by id and calling focus() explicitly — the same technique used for handling focus restoration after dynamic route changes.
Highlighting the Section You Are Reading
A scroll-spy that highlights the current section in the on-this-page list is a nice touch for sighted readers and a hazard for everyone else if it announces itself.
Implementation Guidelines:
- Update only a visual style — a class,
data-active, a border. Do not setaria-currentfrom scroll position; nothing was navigated to. - Never move focus in response to scrolling. Scroll is not an intent signal.
- Use
IntersectionObserverrather than a scroll handler, and skip the work entirely when the list is not visible. - If you must expose the state, do it once on click (the user's own action), not continuously as they scroll.
// Visual highlight only: no ARIA, no focus movement, no announcements.
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.find((entry) => entry.isIntersecting);
if (visible) setActiveId(visible.target.id);
},
{ rootMargin: '-98px 0px -70% 0px' },
);
headings.forEach((heading) => observer.observe(heading));
return () => observer.disconnect();
}, [headings]);
How to Verify
- Screen reader trail read. With the rotor, jump to the "Breadcrumb" landmark and read the list. You should hear "list, 4 items", each link, and the last item announced as the current page.
- Anchor focus check. Activate an on-this-page link, then press
Tab. Focus must continue from inside that section. If it continues down the contents list, the target is not focusable. - Sticky-header check. Jump to a mid-page heading and confirm the heading text is fully visible, not tucked under the header — the practical test for
2.4.11 Focus Not Obscured. - Automated scan. axe flags
aria-valid-attr-valuefor a badaria-currentvalue andduplicate-idwhen two headings generate the same slug. See Automated Accessibility Testing with axe-core. - Deep-link check. Load a URL with a fragment directly. The target should be scrolled into view, clear of the header, and — on a fresh load — focused by the browser.
Common Accessibility Mistakes
- A breadcrumb built from
<div>s and>characters. No list semantics, no count, and the separators are read aloud. Use<ol>and CSS. - Linking the current page to itself. The user activates it and nothing happens. Render the last crumb as a
<span>witharia-current="page". - Using
aria-current="page"on more than one entry. It says the user is in two places at once. Ancestors takearia-current="true"or nothing at all. - Headings without
tabindex="-1". The most common in-page anchor bug: the viewport moves, focus does not, and screen reader users never arrive. - Slugs derived from mutable text. Renaming a heading silently breaks every link to it. Keep an explicit id, or a slug history, for anything you have published.
- Scroll-spy that sets
aria-current. The state changes as the user scrolls, producing announcements nobody asked for. Keep it visual.
Conclusion
Breadcrumbs and in-page anchors are the two smallest navigation components you will build and among the easiest to get subtly wrong. The breadcrumb needs a labelled <nav>, an ordered list, CSS separators and exactly one aria-current="page". The anchors need targets that can hold focus and enough scroll-margin-top to clear the header. Both take about ten minutes to fix and both are invisible in a screenshot, which is exactly why they are worth testing with the keyboard before shipping.
Frequently Asked Questions
Should the breadcrumb include the current page at all?
Yes. It completes the trail and tells the user where they are, which is the point of 2.4.8 Location. Include it as text with aria-current="page" rather than as a link, so it is announced but not offered as a destination.
Is aria-label="Breadcrumb" the right label?
It is the conventional one, and screen readers append the role, so users hear "Breadcrumb navigation". Avoid "Breadcrumb navigation" as the label itself — that produces "breadcrumb navigation navigation".
Do I need aria-current on the on-this-page list?
Only if the user navigated to that section by activating a link, and even then it is optional. Never set it from scroll position: nothing has been navigated to, and the resulting announcements are noise.
How much scroll-margin-top is enough?
The height of everything sticky above the content, plus a few pixels of breathing room. Set it in one place — a custom property updated alongside the header height — so it cannot drift when the header design changes.
What if headings are generated by a markdown pipeline?
Most pipelines add ids automatically. Add tabindex="-1" in the same step (a rehype/remark plugin, or a ProseH2 component in Nuxt Content) so every generated heading is a valid fragment target without authors having to think about it.
Related guides
- Accessible Navigation & Landmark Structure — the parent guide, including landmark naming.
- Building an Accessible Mega Menu in React — the disclosure pattern for large navigation panels.
- Implementing Skip Links in the Next.js App Router — the same focusable-target rule, applied to bypass links.
- Handling Focus Restoration After Dynamic Route Changes — moving focus when the router, not the browser, is in charge.