Pill Breadcrumb
Original · freepill 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-pill.json"use client"
import {
useCallback,
useEffect,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
type Crumb = {
id: string
label: string
href: string
}
type Route = {
id: string
name: string
icon: ReactNode
trail: Crumb[]
}
type RenderItem =
| { kind: "crumb"; crumb: Crumb; isCurrent: boolean }
| { kind: "ellipsis"; hidden: Crumb[] }
function BagIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 shrink-0"
aria-hidden="true"
>
<path d="M6 7h12l1 13H5L6 7Z" />
<path d="M9 7a3 3 0 0 1 6 0" />
</svg>
)
}
function BookIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 shrink-0"
aria-hidden="true"
>
<path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H19v15H6.5A2.5 2.5 0 0 0 4 20.5V5.5Z" />
<path d="M4 20.5A2.5 2.5 0 0 1 6.5 18H19v3H6.5A2.5 2.5 0 0 1 4 20.5Z" />
</svg>
)
}
function PenIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 shrink-0"
aria-hidden="true"
>
<path d="M15 4.5 19.5 9 8.5 20H4v-4.5L15 4.5Z" />
<path d="m13 6.5 4.5 4.5" />
</svg>
)
}
function ChevronIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="h-3.5 w-3.5"
aria-hidden="true"
>
<path d="m9 6 6 6-6 6" />
</svg>
)
}
function EllipsisIcon() {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className="h-4 w-4" aria-hidden="true">
<circle cx="5" cy="12" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="19" cy="12" r="1.6" />
</svg>
)
}
function CopyIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<rect x="9" y="9" width="11" height="11" rx="2.5" />
<path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" />
</svg>
)
}
function CheckIcon() {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="crumbpill-pop h-4 w-4"
aria-hidden="true"
>
<path d="m5 12.5 4.5 4.5L19 6.5" />
</svg>
)
}
const ROUTES: Route[] = [
{
id: "store",
name: "Store",
icon: <BagIcon />,
trail: [
{ id: "s-home", label: "Home", href: "#/" },
{ id: "s-electronics", label: "Electronics", href: "#/electronics" },
{ id: "s-audio", label: "Audio", href: "#/electronics/audio" },
{ id: "s-headphones", label: "Headphones", href: "#/electronics/audio/headphones" },
{ id: "s-overear", label: "Over-Ear", href: "#/electronics/audio/headphones/over-ear" },
{ id: "s-product", label: "Studio Reference 40", href: "#/electronics/audio/headphones/over-ear/sr40" },
],
},
{
id: "docs",
name: "Docs",
icon: <BookIcon />,
trail: [
{ id: "d-docs", label: "Docs", href: "#/docs" },
{ id: "d-components", label: "Components", href: "#/docs/components" },
{ id: "d-navigation", label: "Navigation", href: "#/docs/components/navigation" },
{ id: "d-breadcrumbs", label: "Breadcrumbs", href: "#/docs/components/navigation/breadcrumbs" },
],
},
{
id: "blog",
name: "Journal",
icon: <PenIcon />,
trail: [
{ id: "b-journal", label: "Journal", href: "#/journal" },
{ id: "b-engineering", label: "Engineering", href: "#/journal/engineering" },
{ id: "b-post", label: "Building an accessible breadcrumb", href: "#/journal/engineering/accessible-breadcrumb" },
],
},
]
const COLLAPSE_AFTER = 4
function buildRenderList(trail: Crumb[], expanded: boolean): RenderItem[] {
const lastIndex = trail.length - 1
const shouldCollapse = trail.length > COLLAPSE_AFTER && !expanded
if (!shouldCollapse) {
return trail.map((crumb, i) => ({ kind: "crumb", crumb, isCurrent: i === lastIndex }))
}
const first = trail[0]
const hidden = trail.slice(1, lastIndex - 1)
const tail = trail.slice(lastIndex - 1)
return [
{ kind: "crumb", crumb: first, isCurrent: false },
{ kind: "ellipsis", hidden },
...tail.map((crumb, i) => ({
kind: "crumb" as const,
crumb,
isCurrent: i === tail.length - 1,
})),
]
}
export default function CrumbPill() {
const reduce = useReducedMotion()
const [routeId, setRouteId] = useState<string>(ROUTES[0].id)
const [expanded, setExpanded] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const [copied, setCopied] = useState(false)
const linkRefs = useRef<(HTMLElement | null)[]>([])
const focusAfterExpand = useRef(false)
const copyTimer = useRef<number | null>(null)
const route = ROUTES.find((r) => r.id === routeId) ?? ROUTES[0]
const renderList = buildRenderList(route.trail, expanded)
const count = renderList.length
useEffect(() => {
setExpanded(false)
setActiveIndex(0)
}, [routeId])
useEffect(() => {
if (expanded && focusAfterExpand.current) {
focusAfterExpand.current = false
linkRefs.current[1]?.focus()
}
}, [expanded])
useEffect(() => {
return () => {
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current)
}
}, [])
const handleKeyNav = useCallback(
(e: KeyboardEvent<HTMLElement>, total: number) => {
let next = -1
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
next = Math.min(activeIndex + 1, total - 1)
break
case "ArrowLeft":
case "ArrowUp":
next = Math.max(activeIndex - 1, 0)
break
case "Home":
next = 0
break
case "End":
next = total - 1
break
default:
return
}
e.preventDefault()
setActiveIndex(next)
linkRefs.current[next]?.focus()
},
[activeIndex],
)
const handleExpand = useCallback(() => {
focusAfterExpand.current = true
setExpanded(true)
setActiveIndex(1)
}, [])
const handleCopy = useCallback(async () => {
const text = route.trail.map((c) => c.label).join(" / ")
try {
await navigator.clipboard.writeText(text)
setCopied(true)
if (copyTimer.current !== null) window.clearTimeout(copyTimer.current)
copyTimer.current = window.setTimeout(() => setCopied(false), 1800)
} catch {
setCopied(false)
}
}, [route])
const itemVariants = {
initial: reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.85 },
animate: { opacity: 1, y: 0, scale: 1 },
exit: reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.85 },
}
const pillBase =
"relative inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-sm font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900"
const pillLink =
"border border-zinc-200 bg-white/70 text-zinc-600 hover:border-zinc-300 hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-white"
const pillCurrent =
"overflow-hidden border border-transparent bg-gradient-to-r from-indigo-500 to-violet-500 text-white shadow-sm shadow-indigo-500/30"
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-zinc-50 to-white px-4 py-16 sm:py-24 dark:from-zinc-950 dark:to-zinc-900">
<style>{`
@keyframes crumbpill-sheen {
0% { transform: translateX(-130%) skewX(-18deg); opacity: 0; }
16% { opacity: 0.55; }
52% { opacity: 0; }
100% { transform: translateX(240%) skewX(-18deg); opacity: 0; }
}
@keyframes crumbpill-pop {
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
.crumbpill-sheen {
background: linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%);
animation: crumbpill-sheen 3.8s ease-in-out 1.2s infinite;
}
.crumbpill-pop { animation: crumbpill-pop 0.28s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
@media (prefers-reduced-motion: reduce) {
.crumbpill-sheen { animation: none; opacity: 0; }
.crumbpill-pop { animation: none; }
}
`}</style>
<div className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-indigo-300/20 blur-3xl dark:bg-indigo-500/10" />
<div className="relative mx-auto max-w-3xl">
<header className="mb-8 sm:mb-10">
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white/60 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:border-zinc-700 dark:bg-zinc-900/60 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Navigation
</span>
<h2 className="mt-4 text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl dark:text-zinc-50">
Pill breadcrumbs
</h2>
<p className="mt-2 max-w-xl text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
A rounded breadcrumb trail that collapses long paths behind an
expandable step. Fully keyboard-navigable with a roving focus model.
</p>
</header>
<div className="rounded-3xl border border-zinc-200 bg-white/80 p-6 shadow-xl shadow-zinc-900/5 backdrop-blur-sm sm:p-8 dark:border-zinc-800 dark:bg-zinc-900/70 dark:shadow-black/30">
<div
role="group"
aria-label="Choose an example breadcrumb trail"
className="mb-7 inline-flex rounded-full border border-zinc-200 bg-zinc-100/80 p-1 dark:border-zinc-700 dark:bg-zinc-800/70"
>
{ROUTES.map((r) => {
const active = r.id === routeId
return (
<button
key={r.id}
type="button"
aria-pressed={active}
onClick={() => setRouteId(r.id)}
className={`relative rounded-full px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-800 ${
active
? "text-zinc-900 dark:text-white"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
}`}
>
{active && (
<motion.span
layoutId="crumbpill-seg"
className="absolute inset-0 rounded-full bg-white shadow-sm dark:bg-zinc-700"
transition={{ duration: reduce ? 0 : 0.3, ease: [0.22, 1, 0.36, 1] }}
/>
)}
<span className="relative z-10 inline-flex items-center gap-1.5">
{r.icon}
{r.name}
</span>
</button>
)
})}
</div>
<nav aria-label="Breadcrumb" onKeyDown={(e) => handleKeyNav(e, count)}>
<ol className="flex flex-wrap items-center gap-1.5">
<AnimatePresence mode="popLayout" initial={false}>
{renderList.map((item, i) => {
const key = item.kind === "ellipsis" ? "ellipsis" : item.crumb.id
return (
<motion.li
key={key}
layout
variants={itemVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: reduce ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] }}
className="flex items-center gap-1.5"
>
{i > 0 && (
<span
aria-hidden="true"
className="text-zinc-300 dark:text-zinc-600"
>
<ChevronIcon />
</span>
)}
{item.kind === "crumb" ? (
<a
ref={(el) => {
linkRefs.current[i] = el
}}
href={item.crumb.href}
aria-current={item.isCurrent ? "page" : undefined}
tabIndex={activeIndex === i ? 0 : -1}
onFocus={() => setActiveIndex(i)}
onClick={(e) => {
e.preventDefault()
setActiveIndex(i)
}}
className={`${pillBase} ${item.isCurrent ? pillCurrent : pillLink}`}
>
{i === 0 && (
<span
className={
item.isCurrent
? "text-white"
: "text-indigo-500 dark:text-indigo-400"
}
>
{route.icon}
</span>
)}
<span>{item.crumb.label}</span>
{item.isCurrent && (
<span
aria-hidden="true"
className="crumbpill-sheen pointer-events-none absolute inset-y-0 left-0 w-1/3"
/>
)}
</a>
) : (
<button
ref={(el) => {
linkRefs.current[i] = el
}}
type="button"
onClick={handleExpand}
onFocus={() => setActiveIndex(i)}
tabIndex={activeIndex === i ? 0 : -1}
aria-expanded={false}
aria-label={`Show ${item.hidden.length} hidden ${
item.hidden.length === 1 ? "step" : "steps"
}: ${item.hidden.map((h) => h.label).join(", ")}`}
className={`${pillBase} ${pillLink} px-2.5`}
>
<EllipsisIcon />
</button>
)}
</motion.li>
)
})}
</AnimatePresence>
</ol>
</nav>
<div className="mt-7 flex flex-col gap-4 border-t border-zinc-200/70 pt-5 sm:flex-row sm:items-center sm:justify-between dark:border-zinc-800">
<p className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
Focus a pill, then use{" "}
<kbd className="rounded border border-zinc-300 bg-zinc-100 px-1.5 py-0.5 font-mono text-[0.7rem] text-zinc-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
←
</kbd>{" "}
<kbd className="rounded border border-zinc-300 bg-zinc-100 px-1.5 py-0.5 font-mono text-[0.7rem] text-zinc-600 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
→
</kbd>{" "}
to move. Open the{" "}
<span className="font-medium text-zinc-700 dark:text-zinc-200">⋯</span>{" "}
step to reveal a collapsed trail.
</p>
<button
type="button"
onClick={handleCopy}
className={`inline-flex shrink-0 items-center justify-center gap-2 rounded-full border px-4 py-2 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 ${
copied
? "border-emerald-300 bg-emerald-50 text-emerald-700 focus-visible:ring-emerald-500 dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-300"
: "border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-100 focus-visible:ring-indigo-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? "Path copied" : "Copy path"}
</button>
</div>
<span className="sr-only" role="status" aria-live="polite">
{copied ? "Breadcrumb path copied to clipboard" : ""}
</span>
</div>
</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

Chevron Breadcrumb
Originalchevron separator breadcrumbs

Arrow Breadcrumb
Originalarrow-chip 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.

