core accessibility principles for modern frameworks

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 Ways
  • 2.4.8 Location
  • 2.4.11 Focus Not Obscured (Minimum)
  • 1.3.1 Info and Relationships
Anatomy of an accessible breadcrumb trail A breadcrumb trail rendered as a navigation region labelled Breadcrumb, containing an ordered list of four items. The first three are links: Home, Core principles, and Navigation and landmarks. The fourth is not a link but a span carrying aria-current page. Callouts explain that the ordered list conveys the hierarchy, that separators are drawn in CSS so they are never spoken, and that the current page is present in the trail but not clickable. <nav aria-label="Breadcrumb"> → <ol> Home Core principles Navigation & landmarks Breadcrumbs Current page: a span, not a link aria-current="page" (2.4.8 Location) Separators live in CSS never spoken as "greater than"

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.

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 (::after content 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 BreadcrumbList JSON-LD in a <script type="application/ld+json"> tag rather than sprinkling itemprop attributes 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-label text 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.

What happens when a fragment link is activated Two outcomes of activating an on-this-page link. Without tabindex minus one on the heading, the viewport scrolls but focus stays in the table of contents, so the next Tab press continues through the remaining contents links. With tabindex minus one, focus moves to the heading, the heading is announced, and the next Tab press continues with the first link inside that section. A third panel shows a sticky header covering the target when scroll-margin-top is missing. Plain heading viewport scrolls, focus stays behind in the contents list tabindex="-1" focus moves to the heading and it is announced Sticky header target lands under the chrome unless scroll-margin is set sticky header, 98px tall h2 hidden underneath → scroll-margin-top: 98px 2.4.11 Focus Not Obscured: the focused element must not be completely hidden by other content.

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-top on 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 set aria-current from scroll position; nothing was navigated to.
  • Never move focus in response to scrolling. Scroll is not an intent signal.
  • Use IntersectionObserver rather 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.
Which scroll-spy signals are safe to expose Three rows compare ways of reflecting the section a reader has scrolled to. A CSS class or data attribute is safe because it changes only the visual highlight. Setting aria-current from scroll position is unsafe because it announces a location change the user never requested. Moving focus to the heading on scroll is unsafe because it interrupts reading and can hijack the keyboard mid-key press. Class or data attribute Visual only. Nothing enters the accessibility tree, so nothing is announced. Safe. aria-current from scroll position Announces a location change the reader never asked for, repeatedly. Avoid. Focus moved on scroll Interrupts reading and can steal the caret mid-keystroke. Never do this.
// 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-value for a bad aria-current value and duplicate-id when 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> with aria-current="page".
  • Using aria-current="page" on more than one entry. It says the user is in two places at once. Ancestors take aria-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.