Collapse breadcrumbs
Five breadcrumbs that answer one question: what happens when the trail is too long for the space. An ellipsis, a real dropdown of the hidden levels, a sideways scroll, per-label truncation and a first-plus-last compaction. Each is sized, token-driven and marks the current page with aria-current.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/breadcrumb-collapseDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install lucide-reactCopy the source
components/ui/breadcrumb-collapse.tsx"use client"
import * as React from "react"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
/* Collapse breadcrumb family: 5 strategies for a long trail overflowing. The separator is always a chevron; what changes is how the long trail is shortened (ellipsis, dropdown, horizontal scroll, label truncation, first plus last only). Colour comes ONLY from tokens (alpha via color-mix), the size is a text scale plus gap. The last item is the current page (aria-current), not a link. Deterministic, no motion. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
type Crumb = { label: React.ReactNode; href?: string }
type Props = {
className?: string
size?: StyledSize
items?: Crumb[]
}
const trail: Record<StyledSize, string> = {
sm: "gap-1.5 text-xs",
md: "gap-2 text-sm",
lg: "gap-2.5 text-sm",
xl: "gap-3 text-base",
}
/* The overflow behavior is only visible on a long trail, which is why this family's
default trail is deliberately deep. */
const defaultItems: Crumb[] = [
{ label: "Home", href: "/" },
{ label: "Docs", href: "/docs" },
{ label: "Components", href: "/components" },
{ label: "Navigation", href: "/docs/styled/breadcrumb" },
{ label: "Breadcrumb", href: "/docs/components/breadcrumb" },
{ label: "Collapse" },
]
const link =
"inline-flex items-center gap-1.5 rounded-sm text-muted-foreground transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-3.5 [&_svg]:shrink-0 [&_i]:text-sm [&_i]:leading-none"
const current =
"inline-flex items-center gap-1.5 font-medium text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0 [&_i]:text-sm [&_i]:leading-none"
const sep = "flex items-center text-muted-foreground/60 [&_svg]:size-3.5 [&_svg]:shrink-0 [&>i]:text-sm [&>i]:leading-none"
function Sep() {
return (
<span className={sep} aria-hidden="true">
<ChevronRight />
</span>
)
}
/* A single trail: the last item (or the item with no href) is the current page. */
function CrumbNode({ item, last }: { item: Crumb; last: boolean }) {
if (last || !item.href) {
return (
<span className={current} aria-current="page">
{item.label}
</span>
)
}
return (
<a href={item.href} className={link}>
{item.label}
</a>
)
}
/* Ellipsis: ilk oge + statik uc nokta + son iki oge. Gizli olanlar bir metin
ozeti olarak ekran okuyuculara verilir. */
export function EllipsisBreadcrumb({ className, size = "md", items = defaultItems }: Props) {
const collapsed = items.length > 4
const head = collapsed ? items.slice(0, 1) : items
const tail = collapsed ? items.slice(-2) : []
const hidden = collapsed ? items.length - 3 : 0
return (
<nav data-slot="styled-breadcrumb" aria-label="Breadcrumb">
<ol className={cn("flex flex-wrap items-center", trail[size], className)}>
{head.map((item, i) => (
<li key={`h-${i}`} className="inline-flex items-center gap-1.5">
<CrumbNode item={item} last={!collapsed && i === items.length - 1} />
{(collapsed || i < items.length - 1) && <Sep />}
</li>
))}
{collapsed && (
<li className="inline-flex items-center gap-1.5">
<span
className="flex items-center text-muted-foreground [&>svg]:size-4 [&>svg]:shrink-0 [&>i]:text-base [&>i]:leading-none"
role="presentation"
>
<MoreHorizontal aria-hidden="true" />
<span className="sr-only">{hidden} more levels</span>
</span>
<Sep />
</li>
)}
{tail.map((item, i) => (
<li key={`t-${i}`} className="inline-flex items-center gap-1.5">
<CrumbNode item={item} last={i === tail.length - 1} />
{i < tail.length - 1 && <Sep />}
</li>
))}
</ol>
</nav>
)
}
const menuBtn =
"inline-flex size-6 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_8%,transparent)] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:size-4 [&>svg]:shrink-0 [&>i]:text-base [&>i]:leading-none"
/* Dropdown: the hidden crumbs are listed in a working popover. It closes on Escape and on an outside click; focus moves to the first item when it opens. */
export function DropdownBreadcrumb({ className, size = "md", items = defaultItems }: Props) {
const [open, setOpen] = React.useState(false)
const wrapRef = React.useRef<HTMLLIElement>(null)
const listRef = React.useRef<HTMLDivElement>(null)
const collapsed = items.length > 4
const head = collapsed ? items.slice(0, 1) : items
const middle = collapsed ? items.slice(1, -2) : []
const tail = collapsed ? items.slice(-2) : []
React.useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false)
}
const onDown = (e: MouseEvent) => {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
}
window.addEventListener("keydown", onKey)
window.addEventListener("pointerdown", onDown)
const id = window.requestAnimationFrame(() =>
listRef.current?.querySelector("a")?.focus()
)
return () => {
window.removeEventListener("keydown", onKey)
window.removeEventListener("pointerdown", onDown)
window.cancelAnimationFrame(id)
}
}, [open])
return (
<nav data-slot="styled-breadcrumb" aria-label="Breadcrumb">
<ol className={cn("flex flex-wrap items-center", trail[size], className)}>
{head.map((item, i) => (
<li key={`h-${i}`} className="inline-flex items-center gap-1.5">
<CrumbNode item={item} last={!collapsed && i === items.length - 1} />
{(collapsed || i < items.length - 1) && <Sep />}
</li>
))}
{collapsed && (
<li ref={wrapRef} className="relative inline-flex items-center gap-1.5">
<button
type="button"
className={menuBtn}
aria-label="Show hidden levels"
aria-expanded={open}
aria-haspopup="true"
onClick={() => setOpen((o) => !o)}
>
<MoreHorizontal />
</button>
{open && (
<div
ref={listRef}
className="absolute left-0 top-full z-50 mt-1.5 min-w-40 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg"
>
{middle.map((item, i) => (
<a
key={i}
href={item.href ?? "#"}
className="block rounded-sm px-2 py-1.5 text-sm text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
onClick={() => setOpen(false)}
>
{item.label}
</a>
))}
</div>
)}
<Sep />
</li>
)}
{tail.map((item, i) => (
<li key={`t-${i}`} className="inline-flex items-center gap-1.5">
<CrumbNode item={item} last={i === tail.length - 1} />
{i < tail.length - 1 && <Sep />}
</li>
))}
</ol>
</nav>
)
}
/* Scroll: the trail never shortens, it scrolls horizontally in a narrow space; the
mask at both ends signals that it is scrollable. */
export function ScrollBreadcrumb({ className, size = "md", items = defaultItems }: Props) {
return (
<nav data-slot="styled-breadcrumb" aria-label="Breadcrumb" className="relative max-w-full">
<div className="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [mask-image:linear-gradient(to_right,transparent,black_1.5rem,black_calc(100%-1.5rem),transparent)]">
<ol className={cn("flex w-max flex-nowrap items-center px-6", trail[size], className)}>
{items.map((item, i) => {
const last = i === items.length - 1
return (
<li key={i} className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap">
<CrumbNode item={item} last={last} />
{!last && <Sep />}
</li>
)
})}
</ol>
</div>
</nav>
)
}
const clampWidth: Record<StyledSize, string> = {
sm: "max-w-14",
md: "max-w-16",
lg: "max-w-20",
xl: "max-w-24",
}
/* Truncate: every item stays in place and long labels are clipped to one line; the last item keeps its full width. The full text stays reachable through title. */
export function TruncateBreadcrumb({ className, size = "md", items = defaultItems }: Props) {
return (
<nav data-slot="styled-breadcrumb" aria-label="Breadcrumb" className="max-w-full">
<ol className={cn("flex flex-nowrap items-center overflow-hidden", trail[size], className)}>
{items.map((item, i) => {
const last = i === items.length - 1
const label =
typeof item.label === "string" ? item.label : undefined
return (
<li key={i} className="inline-flex min-w-0 items-center gap-1.5">
{last || !item.href ? (
<span className={cn(current, "block truncate")} aria-current="page" title={label}>
{item.label}
</span>
) : (
<a
href={item.href}
className={cn(link, "block truncate", clampWidth[size])}
title={label}
>
{item.label}
</a>
)}
{!last && <Sep />}
</li>
)
})}
</ol>
</nav>
)
}
/* Compact: the harshest truncation. Only the first and last item remain, the ones between are counted and the count is shown as a badge. */
export function CompactBreadcrumb({ className, size = "md", items = defaultItems }: Props) {
const collapsed = items.length > 2
const hidden = collapsed ? items.length - 2 : 0
const first = items[0]
const last = items[items.length - 1]
return (
<nav data-slot="styled-breadcrumb" aria-label="Breadcrumb">
<ol className={cn("flex flex-wrap items-center", trail[size], className)}>
<li className="inline-flex items-center gap-1.5">
<CrumbNode item={first} last={items.length === 1} />
{items.length > 1 && <Sep />}
</li>
{collapsed && (
<li className="inline-flex items-center gap-1.5">
<span className="inline-flex h-5 items-center rounded-full bg-[color-mix(in_oklab,var(--color-primary)_14%,transparent)] px-2 text-xs font-medium text-primary">
+{hidden}
<span className="sr-only"> hidden levels</span>
</span>
<Sep />
</li>
)}
{items.length > 1 && (
<li className="inline-flex items-center gap-1.5">
<CrumbNode item={last} last />
</li>
)}
</ol>
</nav>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Ellipsis
The middle of the trail folds into a static ellipsis.
import { EllipsisBreadcrumb } from "@/components/ui/breadcrumb-collapse"
<EllipsisBreadcrumb />Dropdown
The hidden levels open in a popover that closes on Escape or an outside click.
import { DropdownBreadcrumb } from "@/components/ui/breadcrumb-collapse"
<DropdownBreadcrumb />Scroll
The full trail stays and scrolls sideways behind a fade mask.
import { ScrollBreadcrumb } from "@/components/ui/breadcrumb-collapse"
<ScrollBreadcrumb />Truncate
Every level stays; long labels clip to a single line.
import { TruncateBreadcrumb } from "@/components/ui/breadcrumb-collapse"
<TruncateBreadcrumb />Compact
Only the first and last level remain, with a count of what is hidden.
import { CompactBreadcrumb } from "@/components/ui/breadcrumb-collapse"
<CompactBreadcrumb />ai2 Collapse breadcrumbs: 5 styled variations on the token system
The ai2 Collapse breadcrumbs are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around breadcrumb trails that are too long for their container. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: these are static; no motion library work is required. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, there is no motion to reduce.
What is in the ai2 Collapse breadcrumbs?
5 exports in one file: Ellipsis, Dropdown, Scroll, Truncate and Compact. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: these are static; no motion library work is required.
- Reduced-motion aware: Under prefers-reduced-motion, there is no motion to reduce.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Collapse breadcrumbs sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.