React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

breadcrumbnavigationroutingcomponenttailwind

Breadcrumb

A horizontal trail of links showing the user's current location within the navigation hierarchy, enabling quick traversal back to parent pages.

Use Cases

  • E-commerce product pages (Home > Category > Subcategory > Product)
  • Documentation sites with nested sections
  • Admin dashboards with multi-level settings
  • File management UIs showing folder paths
  • Multi-step forms indicating the current step
  • Search results showing the category path of each result
  • CMS content editors with nested page hierarchies

Simplest Implementation

"use client";
 
import Link from "next/link";
 
interface BreadcrumbItem {
  label: string;
  href?: string;
}
 
interface BreadcrumbProps {
  items: BreadcrumbItem[];
}
 
export function Breadcrumb({ items }: BreadcrumbProps) {
  return (
    <nav aria-label="Breadcrumb">
      <ol className="flex items-center gap-2 text-sm text-gray-500">
        {items.map((item, index) => (
          <li key={index} className="flex items-center gap-2">
            {index > 0 && <span aria-hidden="true">/</span>}
            {item.href ? (
              <Link href={item.href} className="hover:text-gray-900">
                {item.label}
              </Link>
            ) : (
              <span className="font-medium text-gray-900">{item.label}</span>
            )}
          </li>
        ))}
      </ol>
    </nav>
  );
}

The last item has no href, rendering it as plain text to indicate the current page. The separator uses aria-hidden so screen readers skip it and rely on the <ol> semantics instead.

Variations

With Chevron Separators

"use client";
 
import Link from "next/link";
 
interface BreadcrumbItem {
  label: string;
  href?: string;
}
 
interface BreadcrumbProps {
  items: BreadcrumbItem[];
}
 
export function Breadcrumb({ items }: BreadcrumbProps) {
  return (
    <nav aria-label="Breadcrumb">
      <ol className="flex items-center gap-1.5 text-sm text-gray-500">
        {items.map((item, index) => (
          <li key={index} className="flex items-center gap-1.5">
            {index > 0 && (
              <svg
                className="h-4 w-4 text-gray-400"
                fill="none"
                viewBox="0 0 24 24"
                stroke="currentColor"
                aria-hidden="true"
              >
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
              </svg>
            )}
            {item.href ? (
              <Link href={item.href} className="hover:text-gray-900">
                {item.label}
              </Link>
            ) : (
              <span className="font-medium text-gray-900">{item.label}</span>
            )}
          </li>
        ))}
      </ol>
    </nav>
  );
}

Replaces the slash separator with an inline SVG chevron. The chevron is aria-hidden since the ordered list already conveys the hierarchy to assistive technologies.

With Icons

"use client";
 
import Link from "next/link";
 
interface BreadcrumbItem {
  label: string;
  href?: string;
  icon?: React.ReactNode;
}
 
interface BreadcrumbProps {
  items: BreadcrumbItem[];
}
 
export function Breadcrumb({ items }: BreadcrumbProps) {
  return (
    <nav aria-label="Breadcrumb">
      <ol className="flex items-center gap-1.5 text-sm text-gray-500">
        {items.map((item, index) => (
          <li key={index} className="flex items-center gap-1.5">
            {index > 0 && (
              <svg
                className="h-4 w-4 text-gray-400"
                fill="none"
                viewBox="0 0 24 24"
                stroke="currentColor"
                aria-hidden="true"
              >
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
              </svg>
            )}
            {item.href ? (
              <Link href={item.href} className="flex items-center gap-1.5 hover:text-gray-900">
                {item.icon && <span className="h-4 w-4">{item.icon}</span>}
                {item.label}
              </Link>
            ) : (
              <span className="flex items-center gap-1.5 font-medium text-gray-900">
                {item.icon && <span className="h-4 w-4">{item.icon}</span>}
                {item.label}
              </span>
            )}
          </li>
        ))}
      </ol>
    </nav>
  );
}

Each breadcrumb item can optionally display an icon to the left of its label. This is especially useful for the "Home" item, which is often represented by a house icon instead of text.

Truncated with Ellipsis

"use client";
 
import { useState } from "react";
import Link from "next/link";
 
interface BreadcrumbItem {
  label: string;
  href?: string;
}
 
interface BreadcrumbProps {
  items: BreadcrumbItem[];
  maxVisible?: number;
}
 
export function Breadcrumb({ items, maxVisible = 3 }: BreadcrumbProps) {
  const [expanded, setExpanded] = useState(false);
 
  const shouldTruncate = items.length > maxVisible && !expanded;
  const visibleItems = shouldTruncate
    ? [items[0], ...items.slice(-(maxVisible - 1))]
    : items;
 
  return (
    <nav aria-label="Breadcrumb">
      <ol className="flex items-center gap-1.5 text-sm text-gray-500">
        {visibleItems.map((item, index) => (
          <li key={index} className="flex items-center gap-1.5">
            {index > 0 && <span className="text-gray-400" aria-hidden="true">/</span>}
 
            {/* Ellipsis button inserted after the first item */}
            {shouldTruncate && index === 1 && (
              <>
                <button
                  onClick={() => setExpanded(true)}
                  className="rounded px-1 py-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
                  aria-label="Show full breadcrumb path"
                >
                  ...
                </button>
                <span className="text-gray-400" aria-hidden="true">/</span>
              </>
            )}
 
            {item.href ? (
              <Link href={item.href} className="hover:text-gray-900">
                {item.label}
              </Link>
            ) : (
              <span className="font-medium text-gray-900">{item.label}</span>
            )}
          </li>
        ))}
      </ol>
    </nav>
  );
}

When the breadcrumb trail exceeds maxVisible items, the middle items collapse into a clickable ellipsis. Clicking it reveals the full path. The first and last items always remain visible for context.

With Dropdown for Hidden Items

"use client";
 
import { useState, useRef, useEffect } from "react";
import Link from "next/link";
 
interface BreadcrumbItem {
  label: string;
  href?: string;
}
 
interface BreadcrumbProps {
  items: BreadcrumbItem[];
  maxVisible?: number;
}
 
export function Breadcrumb({ items, maxVisible = 3 }: BreadcrumbProps) {
  const [dropdownOpen, setDropdownOpen] = useState(false);
  const dropdownRef = useRef<HTMLDivElement>(null);
 
  const shouldTruncate = items.length > maxVisible;
  const hiddenItems = shouldTruncate ? items.slice(1, -(maxVisible - 1)) : [];
  const visibleItems = shouldTruncate
    ? [items[0], ...items.slice(-(maxVisible - 1))]
    : items;
 
  useEffect(() => {
    function handleClickOutside(e: MouseEvent) {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
        setDropdownOpen(false);
      }
    }
    if (dropdownOpen) {
      document.addEventListener("mousedown", handleClickOutside);
      return () => document.removeEventListener("mousedown", handleClickOutside);
    }
  }, [dropdownOpen]);
 
  return (
    <nav aria-label="Breadcrumb">
      <ol className="flex items-center gap-1.5 text-sm text-gray-500">
        {visibleItems.map((item, index) => (
          <li key={index} className="flex items-center gap-1.5">
            {index > 0 && <span className="text-gray-400" aria-hidden="true">/</span>}
 
            {shouldTruncate && index === 1 && (
              <>
                <div ref={dropdownRef} className="relative">
                  <button
                    onClick={() => setDropdownOpen((prev) => !prev)}
                    className="rounded px-1.5 py-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
                    aria-label="Show hidden breadcrumb items"
                    aria-expanded={dropdownOpen}
                  >
                    ...
                  </button>
                  {dropdownOpen && (
                    <div className="absolute left-0 top-full z-10 mt-1 min-w-[160px] rounded-lg border bg-white py-1 shadow-lg">
                      {hiddenItems.map((hidden, hIdx) => (
                        <Link
                          key={hIdx}
                          href={hidden.href ?? "#"}
                          className="block px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-100"
                          onClick={() => setDropdownOpen(false)}
                        >
                          {hidden.label}
                        </Link>
                      ))}
                    </div>
                  )}
                </div>
                <span className="text-gray-400" aria-hidden="true">/</span>
              </>
            )}
 
            {item.href ? (
              <Link href={item.href} className="hover:text-gray-900">
                {item.label}
              </Link>
            ) : (
              <span className="font-medium text-gray-900">{item.label}</span>
            )}
          </li>
        ))}
      </ol>
    </nav>
  );
}

Instead of expanding inline, the hidden items appear in a dropdown menu. The dropdown closes on outside click. This is preferable when the full breadcrumb trail is very long and would break the layout.

Dynamic from Route

"use client";
 
import Link from "next/link";
import { usePathname } from "next/navigation";
 
interface BreadcrumbOverride {
  [segment: string]: string;
}
 
interface DynamicBreadcrumbProps {
  overrides?: BreadcrumbOverride;
  homeLabel?: string;
}
 
export function DynamicBreadcrumb({ overrides = {}, homeLabel = "Home" }: DynamicBreadcrumbProps) {
  const pathname = usePathname();
  const segments = pathname.split("/").filter(Boolean);
 
  const items = segments.map((segment, index) => {
    const href = "/" + segments.slice(0, index + 1).join("/");
    const label = overrides[segment] ?? segment.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
    return { label, href };
  });
 
  const all = [{ label: homeLabel, href: "/" }, ...items];
 
  return (
    <nav aria-label="Breadcrumb">
      <ol className="flex items-center gap-1.5 text-sm text-gray-500">
        {all.map((item, index) => (
          <li key={item.href} className="flex items-center gap-1.5">
            {index > 0 && <span className="text-gray-400" aria-hidden="true">/</span>}
            {index === all.length - 1 ? (
              <span className="font-medium text-gray-900">{item.label}</span>
            ) : (
              <Link href={item.href} className="hover:text-gray-900">
                {item.label}
              </Link>
            )}
          </li>
        ))}
      </ol>
    </nav>
  );
}
 
// Usage
// <DynamicBreadcrumb overrides={{ "user-settings": "Settings", "123": "John Doe" }} />

Automatically builds the breadcrumb trail from the current URL path. Segments are title-cased by default, but the overrides map lets you provide human-readable labels for slugs and dynamic IDs.

Complex Implementation

"use client";
 
import { createContext, useContext, useMemo, useState, useRef, useEffect, useCallback } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
 
// --- Types ---
 
interface BreadcrumbItem {
  label: string;
  href?: string;
  icon?: React.ReactNode;
}
 
interface BreadcrumbContextValue {
  items: BreadcrumbItem[];
  setItems: (items: BreadcrumbItem[]) => void;
}
 
// --- Context ---
 
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
 
export function BreadcrumbProvider({ children }: { children: React.ReactNode }) {
  const [items, setItems] = useState<BreadcrumbItem[]>([]);
  return (
    <BreadcrumbContext.Provider value={{ items, setItems }}>
      {children}
    </BreadcrumbContext.Provider>
  );
}
 
export function useBreadcrumb() {
  const ctx = useContext(BreadcrumbContext);
  if (!ctx) throw new Error("useBreadcrumb must be used inside BreadcrumbProvider");
  return ctx;
}
 
// --- Hook to set breadcrumbs from a page ---
 
export function useSetBreadcrumbs(items: BreadcrumbItem[]) {
  const { setItems } = useBreadcrumb();
  const serialized = JSON.stringify(items);
 
  useEffect(() => {
    setItems(JSON.parse(serialized));
  }, [serialized, setItems]);
}
 
// --- Separator ---
 
function Separator() {
  return (
    <svg
      className="h-4 w-4 flex-shrink-0 text-gray-400"
      fill="none"
      viewBox="0 0 24 24"
      stroke="currentColor"
      aria-hidden="true"
    >
      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
    </svg>
  );
}
 
// --- Dropdown for hidden items ---
 
interface EllipsisDropdownProps {
  items: BreadcrumbItem[];
}
 
function EllipsisDropdown({ items }: EllipsisDropdownProps) {
  const [open, setOpen] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    function handleClickOutside(e: MouseEvent) {
      if (ref.current && !ref.current.contains(e.target as Node)) {
        setOpen(false);
      }
    }
    if (open) {
      document.addEventListener("mousedown", handleClickOutside);
      return () => document.removeEventListener("mousedown", handleClickOutside);
    }
  }, [open]);
 
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      if (e.key === "Escape") setOpen(false);
    },
    []
  );
 
  return (
    <div ref={ref} className="relative" onKeyDown={handleKeyDown}>
      <button
        onClick={() => setOpen((prev) => !prev)}
        className="rounded px-1.5 py-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
        aria-label="Show hidden breadcrumb items"
        aria-expanded={open}
        aria-haspopup="true"
      >
        ...
      </button>
      {open && (
        <div
          role="menu"
          className="absolute left-0 top-full z-20 mt-1 min-w-[180px] rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
        >
          {items.map((item, idx) => (
            <Link
              key={idx}
              href={item.href ?? "#"}
              role="menuitem"
              className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
              onClick={() => setOpen(false)}
            >
              {item.icon && <span className="h-4 w-4 flex-shrink-0">{item.icon}</span>}
              {item.label}
            </Link>
          ))}
        </div>
      )}
    </div>
  );
}
 
// --- Breadcrumb Component ---
 
interface BreadcrumbProps {
  items?: BreadcrumbItem[];
  maxVisible?: number;
  separator?: React.ReactNode;
  className?: string;
}
 
export function Breadcrumb({ items: propItems, maxVisible = 4, separator, className }: BreadcrumbProps) {
  const ctx = useContext(BreadcrumbContext);
  const items = propItems ?? ctx?.items ?? [];
 
  const { visible, hidden, shouldTruncate } = useMemo(() => {
    if (items.length <= maxVisible) {
      return { visible: items, hidden: [], shouldTruncate: false };
    }
    return {
      visible: [items[0], ...items.slice(-(maxVisible - 1))],
      hidden: items.slice(1, -(maxVisible - 1)),
      shouldTruncate: true,
    };
  }, [items, maxVisible]);
 
  if (items.length === 0) return null;
 
  const sep = separator ?? <Separator />;
 
  return (
    <nav aria-label="Breadcrumb" className={className}>
      <ol className="flex items-center gap-1.5 text-sm text-gray-500">
        {visible.map((item, index) => {
          const isLast = index === visible.length - 1;
 
          return (
            <li key={index} className="flex items-center gap-1.5">
              {index > 0 && sep}
 
              {shouldTruncate && index === 1 && (
                <>
                  <EllipsisDropdown items={hidden} />
                  {sep}
                </>
              )}
 
              {isLast || !item.href ? (
                <span
                  className={`flex items-center gap-1.5 ${
                    isLast ? "font-medium text-gray-900" : ""
                  }`}
                  aria-current={isLast ? "page" : undefined}
                >
                  {item.icon && <span className="h-4 w-4 flex-shrink-0">{item.icon}</span>}
                  <span className="max-w-[200px] truncate">{item.label}</span>
                </span>
              ) : (
                <Link
                  href={item.href}
                  className="flex items-center gap-1.5 hover:text-gray-900"
                >
                  {item.icon && <span className="h-4 w-4 flex-shrink-0">{item.icon}</span>}
                  <span className="max-w-[200px] truncate">{item.label}</span>
                </Link>
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

Key aspects:

  • Context-driven or prop-driven — the component reads items from either direct props or a BreadcrumbProvider context, enabling pages to declaratively set their breadcrumb trail via useSetBreadcrumbs.
  • Automatic truncation with dropdown — when items exceed maxVisible, middle items collapse into an ellipsis button that reveals a dropdown menu with correct ARIA roles (menu, menuitem).
  • Label truncation — individual labels use max-w-[200px] truncate to prevent extremely long page titles from breaking the layout.
  • aria-current="page" — the last item is marked with aria-current="page" so screen readers announce it as the current location, following WAI-ARIA breadcrumb authoring practices.
  • Custom separator support — the separator prop accepts any ReactNode, defaulting to a chevron SVG. This allows easy customization to slashes, arrows, or other dividers.
  • Keyboard-accessible dropdown — the ellipsis dropdown closes on Escape and on outside click, with proper aria-expanded and aria-haspopup attributes.
  • Serialized effect dependencyuseSetBreadcrumbs serializes the items array to JSON for the effect dependency, avoiding infinite re-render loops from new array references.

Gotchas

  • Using <ul> instead of <ol> — Breadcrumbs represent an ordered sequence. Screen readers announce the position (e.g., "item 2 of 5") with <ol>, which is lost with <ul>.

  • Decorative separators read by screen readers — If separators are inside the list items and not marked aria-hidden="true", screen readers will announce them (e.g., "slash" or "greater than").

  • Making the current page a link — The last breadcrumb item should not be a link since it represents the current page. Linking it creates a confusing self-referential navigation.

  • Not using aria-label="Breadcrumb" on the nav — Without a label, screen readers cannot distinguish the breadcrumb navigation from other <nav> elements on the page.

  • Dynamic breadcrumbs causing layout shift — If breadcrumb items load asynchronously, the component can jump in height. Reserve space or use a skeleton to prevent cumulative layout shift.

  • Truncation hiding important context — Collapsing too aggressively (e.g., maxVisible={2}) can hide the most meaningful part of the path. Choose a threshold that keeps enough context visible.

  • Forgetting aria-current="page" on the last item — This attribute tells assistive technologies which item represents the current page. Without it, the breadcrumb is less useful to screen reader users.

  • Sidebar — Vertical navigation that complements breadcrumbs
  • Pagination — Another navigational component for sequential content
  • Button — Interactive elements used in breadcrumb dropdowns