Build an Accessible, Dynamic React Breadcrumb Component (React Router v6 + Next.js)

Build an accessible, responsive React breadcrumb with dynamic routes, a11y, SEO, and examples for React Router v6 and Next.js.

ASOasis
7 min read
Build an Accessible, Dynamic React Breadcrumb Component (React Router v6 + Next.js)

Image used for representation purposes only.

Why breadcrumb navigation matters

Breadcrumbs show users where they are in an app’s hierarchy and offer quick ways to move up a level. They improve:

  • Orientation: Communicate context within nested pages.
  • Efficiency: Reduce clicks to higher-level screens.
  • Accessibility: Provide a consistent, predictable navigation pattern.
  • SEO: For public sites, structured data can help search engines understand page hierarchy.

This guide walks through a robust, accessible React breadcrumb component with patterns for React Router v6 and Next.js, plus responsiveness, i18n, testing, and SEO.

Accessibility first: the correct semantic structure

Follow the WAI-ARIA Authoring Practices for breadcrumbs:

  • Wrap in a nav element with aria-label=“Breadcrumb”.
  • Use an ordered list (ol) for the trail; each item is an li.
  • Make only non-final items links; mark the final item with aria-current=“page”.
  • Render separators visually only (e.g., with CSS) so screen readers don’t announce them.

Example structure (simplified):

<nav aria-label="Breadcrumb">
  <ol>
    <li><a href="/products">Products</a></li>
    <li><a href="/products/shoes">Shoes</a></li>
    <li aria-current="page">Trail Runners</li>
  </ol>
</nav>

Component API design

Aim for a small, predictable API:

  • items: Array of { label: ReactNode; href?: string }
  • ariaLabel?: string (default “Breadcrumb”)
  • separator?: ReactNode | string (default “/”)
  • maxItems?: number (collapse middle crumbs on small screens)
  • className?: string (styling hooks)
  • structuredData?: boolean (emit JSON-LD when label is string and href is present)

Base Breadcrumbs component (TypeScript)

The following implementation covers accessibility, optional JSON-LD, and a simple middle-collapsing strategy.

import React from 'react';

type Crumb = { label: React.ReactNode; href?: string };

type BreadcrumbsProps = {
  items: Crumb[];
  ariaLabel?: string;
  separator?: React.ReactNode;
  maxItems?: number; // e.g., 4 keeps first + last two + current
  className?: string;
  structuredData?: boolean;
};

export function Breadcrumbs({
  items,
  ariaLabel = 'Breadcrumb',
  separator = '/',
  maxItems = Infinity,
  className,
  structuredData = false,
}: BreadcrumbsProps) {
  const trail = React.useMemo(() => {
    if (items.length <= maxItems) return items;
    // Collapse middle: keep first, last two, and current
    const first = items[0];
    const lastThree = items.slice(-3);
    return [first, { label: '…' }, ...lastThree];
  }, [items, maxItems]);

  const jsonLd = React.useMemo(() => {
    if (!structuredData) return null;
    const serializable = items.every(
      (c) => typeof c.label === 'string' && typeof c.href === 'string'
    );
    if (!serializable) return null;
    const data = {
      '@context': 'https://schema.org',
      '@type': 'BreadcrumbList',
      itemListElement: items.map((c, i) => ({
        '@type': 'ListItem',
        position: i + 1,
        name: c.label as string,
        item: c.href as string,
      })),
    };
    return (
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
      />
    );
  }, [items, structuredData]);

  return (
    <nav aria-label={ariaLabel} className={className}>
      <ol className="rb-ol">
        {trail.map((crumb, i) => {
          const isLast = i === trail.length - 1;
          const isEllipsis = crumb.label === '…' && !('href' in crumb);

          return (
            <li key={i} className="rb-li">
              {isEllipsis ? (
                <span className="rb-ellipsis" aria-hidden="true"></span>
              ) : isLast || !crumb.href ? (
                <span aria-current={isLast ? 'page' : undefined} className="rb-current">{crumb.label}</span>
              ) : (
                <a href={crumb.href} className="rb-link">{crumb.label}</a>
              )}
              {!isLast && !isEllipsis && (
                <span className="rb-sep" aria-hidden="true">{separator}</span>
              )}
            </li>
          );
        })}
      </ol>
      {jsonLd}
    </nav>
  );
}

Minimal, themeable CSS:

.rb-ol { list-style: none; display: flex; flex-wrap: wrap; gap: .5rem; padding: 0; margin: 0; }
.rb-li { display: inline-flex; align-items: center; }
.rb-link { color: var(--rb-link, #2563eb); text-decoration: none; }
.rb-link:hover { text-decoration: underline; }
.rb-current { color: var(--rb-current, #111827); font-weight: 500; }
.rb-sep { margin: 0 .25rem; color: var(--rb-sep, #6b7280); }
.rb-ellipsis { color: var(--rb-sep, #6b7280); }

Notes:

  • The separator is purely visual with aria-hidden=“true”.
  • The last item uses aria-current=“page” when not a link.
  • The optional JSON-LD is added only when labels and hrefs are plain strings.

Using React Router v6: dynamic crumbs with route handles

React Router v6.4+ exposes useMatches(), which returns the matched routes and their handle values. You can store breadcrumb metadata in handle.

Route definitions (excerpt):

// routes.tsx
import { createBrowserRouter } from 'react-router-dom';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    handle: { breadcrumb: 'Home' },
    children: [
      {
        path: 'products',
        element: <ProductsPage />,
        handle: { breadcrumb: 'Products' },
        children: [
          {
            path: ':id',
            loader: async ({ params }) => fetch(`/api/products/${params.id}`).then(r => r.json()),
            element: <ProductPage />,
            handle: {
              breadcrumb: (data: { name: string }) => data.name,
            },
          },
        ],
      },
    ],
  },
]);

Build items from matches:

// BreadcrumbFromRouter.tsx
import { useMatches, Link } from 'react-router-dom';
import { Breadcrumbs } from './Breadcrumbs';

export function BreadcrumbFromRouter() {
  const matches = useMatches();

  const items = matches
    .filter((m) => m.handle && 'breadcrumb' in m.handle)
    .map((m) => {
      const bc = (m.handle as any).breadcrumb;
      const label = typeof bc === 'function' ? bc(m.data) : bc;
      const href = m.pathname; // full matched pathname for the route
      return { label, href };
    });

  // Make the last item non-link for a11y
  if (items.length) items[items.length - 1].href = undefined;

  return <Breadcrumbs items={items} structuredData />;
}

Tips:

  • Use loader data to compute friendly names (e.g., product names) for dynamic params.
  • Prefer m.pathname (from useMatches) over assembling links manually.

Using Next.js App Router: derive crumbs from segments

In Next.js 13+ with the App Router, use usePathname() to derive segments and map them to labels. For dynamic names, fetch data in your page/component and memoize.

// app/components/BreadcrumbFromNext.tsx
'use client';
import React from 'react';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { Breadcrumbs } from './Breadcrumbs';

const LABELS: Record<string, string> = {
  '': 'Home',
  products: 'Products',
  shoes: 'Shoes',
};

export function BreadcrumbFromNext({ currentLabel }: { currentLabel?: string }) {
  const pathname = usePathname(); // e.g., /products/shoes/trail-runners
  const segments = React.useMemo(() => pathname.split('/').filter(Boolean), [pathname]);

  const items = [{ label: 'Home', href: '/' }];
  let hrefAcc = '';
  for (let i = 0; i < segments.length; i++) {
    hrefAcc += `/${segments[i]}`;
    const isLast = i === segments.length - 1;
    const label = isLast && currentLabel ? currentLabel : (LABELS[segments[i]] ?? decodeURIComponent(segments[i]));
    items.push({ label, href: isLast ? undefined : hrefAcc });
  }

  return <Breadcrumbs items={items} structuredData />;
}

Patterns:

  • Accept currentLabel from the page after data fetching to avoid showing raw IDs.
  • Use decodeURIComponent for humanized labels when you don’t have metadata.

Responsiveness: collapsing and overflow

Common strategies for long trails:

  • Middle collapse: Keep first, last two, and current item. Replace the middle with an ellipsis or a dropdown.
  • Horizontal scroll: Allow x-axis scrolling for very dense trails; ensure focus styles and arrow-key navigation remain usable.
  • Abbreviations on narrow viewports: Truncate labels with CSS text-overflow: ellipsis; provide full label in title for a tooltip.

For a disclosure-style middle collapse, replace the ellipsis with a menu button listing hidden crumbs. Ensure the menu is keyboard-operable and respects focus order.

Internationalization and formatting

  • Always pass user-facing labels through your i18n layer.
  • Use dir and lang attributes where appropriate. Example:
  • Truncate thoughtfully. If truncating, expose full labels via title or a tooltip component.

Performance considerations

  • Memoize computed items, especially when deriving from routes or data.
  • Avoid rebuilding arrays on every render; use useMemo with stable dependencies.
  • Prefer CSS separators over extra nodes to reduce DOM size.

Security and data hygiene

  • If labels come from URLs or untrusted sources, sanitize or escape them before rendering. Avoid dangerouslySetInnerHTML for labels.

Testing: unit and a11y

Example with React Testing Library and jest-axe:

import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import { Breadcrumbs } from './Breadcrumbs';

test('announces current page and has no a11y violations', async () => {
  const { container } = render(
    <Breadcrumbs
      items={[
        { label: 'Home', href: '/' },
        { label: 'Products', href: '/products' },
        { label: 'Trail Runners' },
      ]}
    />
  );

  expect(screen.getByText('Trail Runners')).toHaveAttribute('aria-current', 'page');
  expect(await axe(container)).toHaveNoViolations();
});

Visual polish: theming and design tweaks

  • Replace the “/” separator with icons (e.g., chevrons). Keep them aria-hidden.
  • Support dark mode via CSS variables.
  • Add focus-visible styles on links: outline: 2px solid currentColor; outline-offset: 2px.

Common pitfalls checklist

  • Don’t make the current page a link; use aria-current=“page” on a span.
  • Don’t announce separators to screen readers; keep them aria-hidden or use CSS.
  • Ensure the trail order is logical and consistent.
  • Keep routes-to-labels mapping centralized to avoid drift.
  • Emit JSON-LD only with clean strings and full URLs when SEO matters.

Putting it together

  • For static trails, pass an items array directly to Breadcrumbs.
  • For React Router v6+, use useMatches() with route handles to compute items.
  • For Next.js, parse segments with usePathname() and supply a data-driven currentLabel.
  • Add responsive collapse and strong focus styles to keep the control usable on any screen.

A small, well-structured breadcrumb component goes a long way in improving navigation clarity while meeting accessibility standards and SEO best practices.

Related Posts