Navigationv0.1.0
Animated Breadcrumb
Items slide in; overflow collapses into a menu.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/animated-breadcrumb.jsonManual
Install the dependencies
npm install lucide-react motion radix-uiAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/animated-breadcrumb/animated-breadcrumb.tsx"use client"; import { useState, type ComponentProps, type ElementType, type MouseEvent, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { DropdownMenu } from "radix-ui"; import { ChevronRight, MoreHorizontal } from "lucide-react"; import { duration, ease, overlayVariants, spring } from "@/lib/motion"; export interface BreadcrumbItem { /** Visible text. */ label: string; /** Destination; the last item is the current page and is never a link. */ href?: string; /** Icon before the label. */ icon?: ReactNode; } export interface AnimatedBreadcrumbProps extends Omit<ComponentProps<"nav">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Trail from the root to the current page. */ items: BreadcrumbItem[]; /** Above this many items, the middle ones collapse into a menu. @default 4 */ maxItems?: number; /** Separator between items. @default <ChevronRight /> */ separator?: ReactNode; /** Link component, e.g. Next.js Link. @default "a" */ linkComponent?: ElementType; /** Called when an item (including one in the overflow menu) is clicked. */ onNavigate?: (item: BreadcrumbItem, index: number, event: MouseEvent<HTMLElement>) => void; } type Entry = { kind: "item"; item: BreadcrumbItem; index: number } | { kind: "overflow"; items: { item: BreadcrumbItem; index: number }[] }; export function AnimatedBreadcrumb({ items, maxItems = 4, separator, linkComponent: Link = "a", onNavigate, className, "aria-label": ariaLabel = "Breadcrumb", ...props }: AnimatedBreadcrumbProps) { const [menuOpen, setMenuOpen] = useState(false); const reduceMotion = useReducedMotion(); const indexed = items.map((item, index) => ({ item, index })); // Keep the root and the last (maxItems − 2) items; everything in between goes into the menu. const tail = Math.max(maxItems - 2, 1); const entries: Entry[] = items.length > maxItems ? [ { kind: "item", ...indexed[0] }, { kind: "overflow", items: indexed.slice(1, items.length - tail) }, ...indexed.slice(items.length - tail).map((e) => ({ kind: "item" as const, ...e })), ] : indexed.map((e) => ({ kind: "item" as const, ...e })); const enter = { initial: reduceMotion ? { opacity: 0 } : { opacity: 0, x: -8, filter: "blur(3px)" }, animate: { opacity: 1, x: 0, filter: "blur(0px)" }, exit: { opacity: 0, x: reduceMotion ? 0 : -4, filter: "blur(0px)", transition: { duration: duration.instant } }, }; const sep = ( <span aria-hidden="true" className="flex text-muted-foreground/60 [&_svg]:size-3.5"> {separator ?? <ChevronRight />} </span> ); return ( <nav aria-label={ariaLabel} className={className} {...props}> <ol className="flex flex-wrap items-center gap-1.5 text-sm"> <AnimatePresence mode="popLayout" initial={false}> {entries.map((entry, position) => { const key = entry.kind === "overflow" ? "overflow" : `${entry.index}:${entry.item.label}`; const isLast = entry.kind === "item" && entry.index === items.length - 1; return ( <motion.li key={key} layout={!reduceMotion} {...enter} transition={{ duration: duration.base, ease: ease.out, layout: spring.smooth }} className="flex items-center gap-1.5" > {position > 0 ? sep : null} {entry.kind === "overflow" ? ( <DropdownMenu.Root open={menuOpen} onOpenChange={setMenuOpen}> <DropdownMenu.Trigger aria-label={`Show ${entry.items.length} more`} className="inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors after:absolute after:-inset-1.5 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-accent relative" > <MoreHorizontal className="size-4" aria-hidden="true" /> </DropdownMenu.Trigger> <AnimatePresence> {menuOpen ? ( <DropdownMenu.Portal forceMount> <DropdownMenu.Content forceMount asChild align="start" sideOffset={6} collisionPadding={8}> <motion.div variants={overlayVariants(reduceMotion)} initial="hidden" animate="visible" exit="exit" style={{ transformOrigin: "var(--radix-dropdown-menu-content-transform-origin)" }} className="z-50 min-w-44 rounded-[10px] border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none" > {entry.items.map(({ item, index }) => ( <DropdownMenu.Item key={`${index}:${item.label}`} asChild> <Link href={item.href} onClick={(event: MouseEvent<HTMLElement>) => onNavigate?.(item, index, event)} className="flex h-9 cursor-pointer items-center gap-2 rounded-md px-2.5 text-sm text-foreground outline-none data-[highlighted]:bg-accent [&_svg]:size-4 [&_svg]:text-muted-foreground" > {item.icon} {item.label} </Link> </DropdownMenu.Item> ))} </motion.div> </DropdownMenu.Content> </DropdownMenu.Portal> ) : null} </AnimatePresence> </DropdownMenu.Root> ) : isLast ? ( <span aria-current="page" className="flex items-center gap-1.5 font-medium text-foreground [&_svg]:size-4"> {entry.item.icon} {entry.item.label} </span> ) : ( <Link href={entry.item.href} onClick={(event: MouseEvent<HTMLElement>) => onNavigate?.(entry.item, entry.index, event)} className="flex items-center gap-1.5 rounded-sm text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-4" > {entry.item.icon} {entry.item.label} </Link> )} </motion.li> ); })} </AnimatePresence> </ol> </nav> ); }
Usage
import Link from "next/link";
import { AnimatedBreadcrumb } from "@/components/ui/animated-breadcrumb/animated-breadcrumb";
export function PageTrail() {
return (
<AnimatedBreadcrumb
linkComponent={Link}
items={[
{ label: "Home", href: "/" },
{ label: "Projects", href: "/projects" },
{ label: "Aurora" },
]}
/>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
items* | BreadcrumbItem[] | – | Trail from the root to the current page. |
maxItems | number | 4 | Above this many items, the middle ones collapse into a menu. |
separator | ReactNode | <ChevronRight /> | Separator between items. |
linkComponent | ElementType | "a" | Link component, e.g. Next.js Link. |
onNavigate | (item: BreadcrumbItem, index: number, event: MouseEvent<HTMLElement>) => void | – | Called when an item (including one in the overflow menu) is clicked. |
Also accepts every native <nav> attribute, including ref and className.
- New levels slide in from the left with a blur, and the others glide to make room through a Motion layout animation.
- Above maxItems, the trail keeps the root and the last maxItems − 2 levels, and the rest move into the menu.
Accessibility
- A labelled <nav> with an ordered list, and the current page marked with aria-current="page".
- Separators are aria-hidden, so screen readers read only the trail.
- Collapsed levels sit behind a menu button labelled with how many it hides ("Show 3 more"); the menu is a Radix DropdownMenu with full keyboard support.
- With reduced motion enabled, items fade in and out without sliding.