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
BreadcrumbProvidercontext, enabling pages to declaratively set their breadcrumb trail viauseSetBreadcrumbs. - 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] truncateto prevent extremely long page titles from breaking the layout. aria-current="page"— the last item is marked witharia-current="page"so screen readers announce it as the current location, following WAI-ARIA breadcrumb authoring practices.- Custom separator support — the
separatorprop 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-expandedandaria-haspopupattributes. - Serialized effect dependency —
useSetBreadcrumbsserializes 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.
Related
- Sidebar — Vertical navigation that complements breadcrumbs
- Pagination — Another navigational component for sequential content
- Button — Interactive elements used in breadcrumb dropdowns