Breadcrumb
A horizontal trail of links showing the user's current location within the navigation hierarchy, enabling quick traversal back to parent pages.
Search across all documentation pages
A horizontal trail of links showing the user's current location within the navigation hierarchy, enabling quick traversal back to parent pages.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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.
"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.
"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.
"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.
"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.
"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.
"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:
BreadcrumbProvider context, enabling pages to declaratively set their breadcrumb trail via useSetBreadcrumbs.maxVisible, middle items collapse into an ellipsis button that reveals a dropdown menu with correct ARIA roles (menu, menuitem).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.separator prop accepts any ReactNode, defaulting to a chevron SVG. This allows easy customization to slashes, arrows, or other dividers.aria-expanded and aria-haspopup attributes.useSetBreadcrumbs serializes the items array to JSON for the effect dependency, avoiding infinite re-render loops from new array references.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.
Reviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥