Motion breadcrumbs
Five breadcrumbs that add behaviour to the trail: a hover lift, a staggered slide-in, a staggered fade-in, a growing underline and links that lean toward the pointer. Every stagger delay derives from the crumb index, so the result is deterministic. 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-motionDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/breadcrumb-motion.tsx"use client"
import type * as React from "react"
import { ChevronRight } from "lucide-react"
import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react"
import { cn } from "@/lib/utils"
/* Motion breadcrumb family: 5 motion techniques. The separator is always a
chevron; what changes is how the trail behaves (lifting on hover, sliding in
from the left in sequence, fading in in sequence, an underline that grows on
hover, a magnetic trail that leans toward the cursor). All of them are disabled
through useReducedMotion: in reduced mode there is no transform, only an
instant appearance or a fade. Color comes ONLY from tokens (via alpha
color-mix), size covers the text scale + gap. The last item is the current page
(aria-current), not a link. Deterministic: the delays derive from the index,
with no randomness. */
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",
}
const defaultItems: Crumb[] = [
{ label: "Home", href: "#" },
{ label: "Components", href: "#" },
{ label: "Button" },
]
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>
)
}
const softSpring = { type: "spring" as const, stiffness: 380, damping: 30, mass: 0.6 }
/* Hover: every link rises and grows a notch on hover and focus. Only the colour changes under reduced. */
export function HoverBreadcrumb({ className, size = "md", items = defaultItems }: Props) {
const reduce = useReducedMotion()
return (
<nav data-slot="styled-breadcrumb" aria-label="Breadcrumb">
<ol className={cn("flex flex-wrap items-center", trail[size], className)}>
{items.map((item, i) => {
const last = i === items.length - 1
return (
<li key={i} className="inline-flex items-center gap-1.5">
{last || !item.href ? (
<span className={current} aria-current="page">
{item.label}
</span>
) : (
<motion.a
href={item.href}
className={link}
whileHover={reduce ? undefined : { y: -2, scale: 1.04 }}
whileFocus={reduce ? undefined : { y: -2, scale: 1.04 }}
whileTap={reduce ? undefined : { scale: 0.98 }}
transition={softSpring}
>
{item.label}
</motion.a>
)}
{!last && <Sep />}
</li>
)
})}
</ol>
</nav>
)
}
/* Slide: the crumbs slide in from the left in sequence; the delay derives from the
index. In reduced mode there is no slide, only a fade. */
export function SlideBreadcrumb({ className, size = "md", items = defaultItems }: Props) {
const reduce = useReducedMotion()
return (
<nav data-slot="styled-breadcrumb" aria-label="Breadcrumb">
<ol className={cn("flex flex-wrap items-center", trail[size], className)}>
{items.map((item, i) => {
const last = i === items.length - 1
return (
<motion.li
key={i}
className="inline-flex items-center gap-1.5"
initial={reduce ? { opacity: 0 } : { opacity: 0, x: -12 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, x: 0 }}
transition={{ duration: 0.28, delay: i * 0.06, ease: "easeOut" }}
>
{last || !item.href ? (
<span className={current} aria-current="page">
{item.label}
</span>
) : (
<a href={item.href} className={link}>
{item.label}
</a>
)}
{!last && <Sep />}
</motion.li>
)
})}
</ol>
</nav>
)
}
/* Fade: the crumbs stay in place and only the opacity opens in sequence. Safe in
reduced mode too: a fade contains no transform anyway, it just speeds up. */
export function FadeBreadcrumb({ className, size = "md", items = defaultItems }: Props) {
const reduce = useReducedMotion()
return (
<nav data-slot="styled-breadcrumb" aria-label="Breadcrumb">
<ol className={cn("flex flex-wrap items-center", trail[size], className)}>
{items.map((item, i) => {
const last = i === items.length - 1
return (
<motion.li
key={i}
className="inline-flex items-center gap-1.5"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: reduce ? 0.15 : 0.35, delay: reduce ? 0 : i * 0.08 }}
>
{last || !item.href ? (
<span className={current} aria-current="page">
{item.label}
</span>
) : (
<a href={item.href} className={link}>
{item.label}
</a>
)}
{!last && <Sep />}
</motion.li>
)
})}
</ol>
</nav>
)
}
/* Underline: an underline growing from left to right on hover and focus. Under reduced it does not grow, it appears instantly. */
export function UnderlineBreadcrumb({ className, size = "md", items = defaultItems }: Props) {
const reduce = useReducedMotion()
return (
<nav data-slot="styled-breadcrumb" aria-label="Breadcrumb">
<ol className={cn("flex flex-wrap items-center", trail[size], className)}>
{items.map((item, i) => {
const last = i === items.length - 1
return (
<li key={i} className="inline-flex items-center gap-1.5">
{last || !item.href ? (
<span className={current} aria-current="page">
{item.label}
</span>
) : (
<motion.a
href={item.href}
className={cn(link, "relative")}
initial="rest"
whileHover="active"
whileFocus="active"
animate="rest"
>
{item.label}
<motion.span
aria-hidden="true"
className="absolute -bottom-0.5 left-0 h-px w-full origin-left bg-primary"
variants={
reduce
? { rest: { opacity: 0 }, active: { opacity: 1 } }
: { rest: { scaleX: 0 }, active: { scaleX: 1 } }
}
transition={{ duration: 0.22, ease: "easeOut" }}
/>
</motion.a>
)}
{!last && <Sep />}
</li>
)
})}
</ol>
</nav>
)
}
/* Tek magnetik link: imlecin kutu merkezinden sapmasini sinirli olcude izler.
Pointer ayrilinca yaya ile merkeze doner. */
function MagneticLink({ item }: { item: Crumb }) {
const reduce = useReducedMotion()
const x = useMotionValue(0)
const y = useMotionValue(0)
const sx = useSpring(x, softSpring)
const sy = useSpring(y, softSpring)
const onMove = (e: React.PointerEvent<HTMLAnchorElement>) => {
if (reduce) return
const r = e.currentTarget.getBoundingClientRect()
const dx = e.clientX - (r.left + r.width / 2)
const dy = e.clientY - (r.top + r.height / 2)
x.set(Math.max(-6, Math.min(6, dx * 0.3)))
y.set(Math.max(-4, Math.min(4, dy * 0.3)))
}
const reset = () => {
x.set(0)
y.set(0)
}
return (
<motion.a
href={item.href}
className={link}
style={reduce ? undefined : { x: sx, y: sy }}
onPointerMove={onMove}
onPointerLeave={reset}
onBlur={reset}
>
{item.label}
</motion.a>
)
}
/* Magnetic: the links lean slightly towards the cursor. Under reduced no transform is applied at all and the link stays completely still. */
export function MagneticBreadcrumb({ className, size = "md", items = defaultItems }: Props) {
return (
<nav data-slot="styled-breadcrumb" aria-label="Breadcrumb">
<ol className={cn("flex flex-wrap items-center", trail[size], className)}>
{items.map((item, i) => {
const last = i === items.length - 1
return (
<li key={i} className="inline-flex items-center gap-1.5">
{last || !item.href ? (
<span className={current} aria-current="page">
{item.label}
</span>
) : (
<MagneticLink item={item} />
)}
{!last && <Sep />}
</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.
Hover
Each link lifts and grows a step on hover or focus.
import { HoverBreadcrumb } from "@/components/ui/breadcrumb-motion"
<HoverBreadcrumb />Slide
The crumbs slide in from the left, one after another.
import { SlideBreadcrumb } from "@/components/ui/breadcrumb-motion"
<SlideBreadcrumb />Fade
The crumbs stay in place and fade in on a stagger.
import { FadeBreadcrumb } from "@/components/ui/breadcrumb-motion"
<FadeBreadcrumb />Underline
An underline grows from the left on hover or focus.
import { UnderlineBreadcrumb } from "@/components/ui/breadcrumb-motion"
<UnderlineBreadcrumb />Magnetic
Links lean toward the pointer and spring back on leave.
import { MagneticBreadcrumb } from "@/components/ui/breadcrumb-motion"
<MagneticBreadcrumb />ai2 Motion breadcrumbs: 5 styled variations on the token system
The ai2 Motion breadcrumbs are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around breadcrumb trails with hover and enter motion. 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: framer-motion drives the hover lift, the staggered enter, the underline and the magnetic pull. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the transforms are dropped and the crumbs fade or appear instantly.
What is in the ai2 Motion breadcrumbs?
5 exports in one file: Hover, Slide, Fade, Underline and Magnetic. 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: framer-motion drives the hover lift, the staggered enter, the underline and the magnetic pull.
- Reduced-motion aware: Under prefers-reduced-motion, the transforms are dropped and the crumbs fade or appear instantly.
- 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 Motion 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.