Chevron Breadcrumb
Original · freechevron separator breadcrumbs
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/crumb-chevron.json"use client";
import {
useState,
useRef,
useEffect,
useId,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
type Crumb = { label: string; href: string };
type Trail = { id: string; label: string; hint: string; crumbs: Crumb[] };
type CrumbNode =
| { kind: "crumb"; crumb: Crumb; isFirst: boolean; isCurrent: boolean }
| { kind: "overflow"; items: Crumb[] };
const TRAILS: Trail[] = [
{
id: "docs",
label: "Docs",
hint: "Deep documentation path",
crumbs: [
{ label: "Home", href: "#home" },
{ label: "Documentation", href: "#docs" },
{ label: "Components", href: "#components" },
{ label: "Navigation", href: "#navigation" },
{ label: "Breadcrumbs", href: "#breadcrumbs" },
{ label: "Chevron separator", href: "#chevron" },
],
},
{
id: "store",
label: "Store",
hint: "Nested product catalog",
crumbs: [
{ label: "Home", href: "#home" },
{ label: "Store", href: "#store" },
{ label: "Audio", href: "#audio" },
{ label: "Headphones", href: "#headphones" },
{ label: "Studio monitors", href: "#studio" },
{ label: "HX-90 Reference", href: "#hx90" },
],
},
{
id: "account",
label: "Account",
hint: "Short, uncollapsed trail",
crumbs: [
{ label: "Home", href: "#home" },
{ label: "Account settings", href: "#account" },
{ label: "Billing & invoices", href: "#billing" },
],
},
];
const RING =
"outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";
function ChevronSeparator() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="mx-1 h-4 w-4 shrink-0 text-slate-300 dark:text-slate-600 sm:mx-1.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m9 6 6 6-6 6" />
</svg>
);
}
function HomeIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-4 w-4 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 10.5 12 3l9 7.5" />
<path d="M5 9.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1V9.5" />
</svg>
);
}
function DotsIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="currentColor">
<circle cx="5" cy="12" r="1.75" />
<circle cx="12" cy="12" r="1.75" />
<circle cx="19" cy="12" r="1.75" />
</svg>
);
}
function OverflowMenu({ items, reduced }: { items: Crumb[]; reduced: boolean }) {
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const wrapRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const itemRefs = useRef<Array<HTMLAnchorElement | null>>([]);
const menuId = useId();
useEffect(() => {
if (open) itemRefs.current[activeIndex]?.focus();
}, [open, activeIndex]);
useEffect(() => {
if (!open) return;
function handlePointer(e: Event) {
const target = e.target as globalThis.Node | null;
if (wrapRef.current && target && !wrapRef.current.contains(target)) {
setOpen(false);
}
}
document.addEventListener("pointerdown", handlePointer);
return () => document.removeEventListener("pointerdown", handlePointer);
}, [open]);
function openMenu(index: number) {
setActiveIndex(index);
setOpen(true);
}
function close(focusButton: boolean) {
setOpen(false);
if (focusButton) buttonRef.current?.focus();
}
function onButtonKeyDown(e: ReactKeyboardEvent<HTMLButtonElement>) {
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
openMenu(0);
} else if (e.key === "ArrowUp") {
e.preventDefault();
openMenu(items.length - 1);
}
}
function onItemKeyDown(e: ReactKeyboardEvent<HTMLAnchorElement>, index: number) {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((index + 1) % items.length);
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((index - 1 + items.length) % items.length);
break;
case "Home":
e.preventDefault();
setActiveIndex(0);
break;
case "End":
e.preventDefault();
setActiveIndex(items.length - 1);
break;
case "Escape":
e.preventDefault();
close(true);
break;
case "Tab":
setOpen(false);
break;
default:
break;
}
}
const panelMotion = reduced
? {}
: {
initial: { opacity: 0, y: -6, scale: 0.97 },
animate: { opacity: 1, y: 0, scale: 1 },
exit: { opacity: 0, y: -6, scale: 0.97 },
transition: { duration: 0.16, ease: "easeOut" as const },
};
return (
<div ref={wrapRef} className="relative inline-flex">
<button
ref={buttonRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
aria-label={`Show ${items.length} hidden breadcrumb levels`}
onClick={() => (open ? close(false) : openMenu(0))}
onKeyDown={onButtonKeyDown}
className={`inline-flex h-7 items-center justify-center rounded-md px-1.5 text-slate-500 transition hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 ${RING}`}
>
<DotsIcon />
</button>
<AnimatePresence>
{open && (
<motion.div
{...panelMotion}
className="absolute left-0 top-full z-20 mt-2 min-w-[13rem] origin-top-left"
>
<ul
id={menuId}
role="menu"
aria-label="Hidden breadcrumb levels"
className="overflow-hidden rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-800 dark:shadow-black/40"
>
{items.map((item, i) => (
<li key={item.href} role="none">
<a
role="menuitem"
href={item.href}
ref={(el) => {
itemRefs.current[i] = el;
}}
tabIndex={i === activeIndex ? 0 : -1}
onKeyDown={(e) => onItemKeyDown(e, i)}
onClick={() => setOpen(false)}
className={`flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-600 transition hover:bg-indigo-50 hover:text-indigo-700 dark:text-slate-300 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300 ${RING}`}
>
<span
aria-hidden="true"
className="h-1.5 w-1.5 shrink-0 rounded-full bg-slate-300 dark:bg-slate-600"
/>
{item.label}
</a>
</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function TrailPicker({
trails,
activeId,
onChange,
}: {
trails: Trail[];
activeId: string;
onChange: (id: string) => void;
}) {
const refs = useRef<Array<HTMLButtonElement | null>>([]);
const activeIndex = trails.findIndex((t) => t.id === activeId);
function onKeyDown(e: ReactKeyboardEvent<HTMLButtonElement>) {
const len = trails.length;
let next = activeIndex;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (activeIndex + 1) % len;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (activeIndex - 1 + len) % len;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = len - 1;
else return;
e.preventDefault();
onChange(trails[next].id);
refs.current[next]?.focus();
}
return (
<div
role="radiogroup"
aria-label="Example breadcrumb path"
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-slate-100/70 p-1 dark:border-slate-800 dark:bg-slate-800/60"
>
{trails.map((trail, i) => {
const selected = trail.id === activeId;
return (
<button
key={trail.id}
ref={(el) => {
refs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => onChange(trail.id)}
onKeyDown={onKeyDown}
className={`relative rounded-lg px-3.5 py-1.5 text-sm font-medium transition ${RING} ${
selected
? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
}`}
>
{trail.label}
</button>
);
})}
</div>
);
}
const STYLES = `
@keyframes cc-crumb-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes cc-dot-pulse {
0%, 100% { opacity: 0.4; transform: scale(0.82); box-shadow: 0 0 0 0 rgba(99,102,241,0.5); }
50% { opacity: 1; transform: scale(1); box-shadow: 0 0 0 5px rgba(99,102,241,0); }
}
.cc-crumb { animation: cc-crumb-in 0.5s cubic-bezier(0.16,1,0.3,1) both; }
.cc-dot { animation: cc-dot-pulse 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.cc-crumb, .cc-dot { animation: none !important; }
}
`;
export default function CrumbChevron() {
const reduced = useReducedMotion() ?? false;
const [activeId, setActiveId] = useState<string>(TRAILS[0].id);
const active = TRAILS.find((t) => t.id === activeId) ?? TRAILS[0];
const crumbs = active.crumbs;
const shouldCollapse = crumbs.length > 4;
const nodes: CrumbNode[] = [];
if (!shouldCollapse) {
crumbs.forEach((crumb, i) =>
nodes.push({
kind: "crumb",
crumb,
isFirst: i === 0,
isCurrent: i === crumbs.length - 1,
}),
);
} else {
const middle = crumbs.slice(1, -2);
const tail = crumbs.slice(-2);
nodes.push({ kind: "crumb", crumb: crumbs[0], isFirst: true, isCurrent: false });
nodes.push({ kind: "overflow", items: middle });
tail.forEach((crumb, i) =>
nodes.push({
kind: "crumb",
crumb,
isFirst: false,
isCurrent: i === tail.length - 1,
}),
);
}
const current = crumbs[crumbs.length - 1];
const hiddenCount = shouldCollapse ? crumbs.length - 3 : 0;
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-6 py-20 dark:from-slate-950 dark:to-slate-900 sm:px-8 lg:py-28">
<style>{STYLES}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-40 bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.12),transparent)] dark:bg-[radial-gradient(60%_100%_at_50%_0%,rgba(99,102,241,0.18),transparent)]"
/>
<div className="relative mx-auto w-full max-w-3xl">
<div className="mb-8">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium uppercase tracking-wider text-indigo-600 dark:border-slate-800 dark:bg-slate-900 dark:text-indigo-400">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Navigation pattern
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
Chevron breadcrumbs
</h2>
<p className="mt-3 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-400">
A trail that shows where you are and how you got there. Long paths collapse into a
keyboard-navigable menu, so the current page always stays in view.
</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-sm shadow-slate-900/5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:shadow-black/20 sm:p-7">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<TrailPicker trails={TRAILS} activeId={activeId} onChange={setActiveId} />
<p className="text-xs text-slate-500 dark:text-slate-400">{active.hint}</p>
</div>
<nav aria-label="Breadcrumb" className="min-h-[2.25rem]">
<ol key={activeId} className="flex flex-wrap items-center gap-y-1.5">
{nodes.map((node, i) => (
<li
key={node.kind === "overflow" ? "overflow" : node.crumb.href}
className="cc-crumb flex items-center"
style={{ animationDelay: `${i * 55}ms` }}
>
{i > 0 && <ChevronSeparator />}
{node.kind === "overflow" ? (
<OverflowMenu items={node.items} reduced={reduced} />
) : node.isCurrent ? (
<span
aria-current="page"
className="inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-semibold text-slate-900 dark:text-white"
>
{node.crumb.label}
</span>
) : (
<a
href={node.crumb.href}
className={`inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-slate-500 transition hover:text-indigo-600 dark:text-slate-400 dark:hover:text-indigo-300 ${RING}`}
>
{node.isFirst && <HomeIcon />}
{node.crumb.label}
</a>
)}
</li>
))}
</ol>
</nav>
<div className="mt-6 flex items-center gap-3 border-t border-slate-200 pt-5 dark:border-slate-800">
<span
aria-hidden="true"
className="cc-dot h-2.5 w-2.5 shrink-0 rounded-full bg-indigo-500"
/>
<p className="text-sm text-slate-600 dark:text-slate-400">
You are viewing{" "}
<span className="font-semibold text-slate-900 dark:text-white">{current.label}</span>
{hiddenCount > 0 && (
<span className="text-slate-400 dark:text-slate-500">
{" "}
· {hiddenCount} levels collapsed
</span>
)}
</p>
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-400 dark:text-slate-500">
Tip: focus a link and use Tab, or open the “…” menu with Enter and browse it with the arrow keys.
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Breadcrumb
Originalbasic breadcrumb trail

Slash Breadcrumb
Originalslash separator breadcrumbs

Arrow Breadcrumb
Originalarrow-chip breadcrumbs

Pill Breadcrumb
Originalpill breadcrumbs
Icons Breadcrumb
Originalbreadcrumbs with icons

Collapsed Breadcrumb
Originalcollapsed breadcrumbs with ellipsis

Dropdown Breadcrumb
Originalbreadcrumb with a dropdown level

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

