Back
React
Motion
Tailwind
Breadcrumbs
It is built using React, Framer Motion, and Tailwind CSS.
Description
A breadcrumb component that can be copied into any React or Next app. The breadcrumbs can be built based on the users URL or manually as a if you want to really customise.
Usage
- Install the dependencies
NPM
npm install motion tailwind tailwindmerge clsx
Bun
bun add motion tailwind tailwindmerge clsx
- Add the util file for tailwind merge @/utils/cn.ts
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
- Copy the source code in your project @/components/breadcrumbs.tsx
import { Breadcrumbs } from "@/components/ui/breadcrumbs";
Basic Usage
"use client";
import React, { useEffect, useState, useCallback } from "react";
import { ChevronRight, Home } from "lucide-react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
// Types
export interface BreadcrumbItem {
label: string;
href?: string;
icon?: React.ReactNode;
isActive?: boolean;
}
export interface BreadcrumbProps {
mode: "url" | "custom";
items?: BreadcrumbItem[];
separator?: "chevron" | "slash" | "line" | "custom";
customSeparator?: React.ReactNode;
showHome?: boolean;
homeLabel?: string;
homeHref?: string;
variant?: "default" | "pills" | "bordered";
navClassName?: string;
crumbClassNames?: string;
maxItems?: number;
onItemClick?: (item: BreadcrumbItem, index: number) => void;
collapsible?: boolean;
noAnimations?: boolean;
}
// Basic Line Separator (instead of chevron)
export const LineSeparator = () => (
<div className="flex items-center justify-center">
<div className="h-[1px] w-3 bg-gray-800 rounded-xs dark:bg-white"></div>
</div>
);
export const Breadcrumb: React.FC<BreadcrumbProps> = ({
mode = "url",
items,
separator = "chevron",
customSeparator,
showHome = true,
homeLabel = "",
homeHref = "/",
variant = "default",
navClassName = "",
crumbClassNames = "",
maxItems = 5,
onItemClick,
collapsible = false,
noAnimations,
}) => {
const [visibleItems, setVisibleItems] = React.useState<BreadcrumbItem[]>([]);
const [hiddenItems, setHiddenItems] = React.useState<BreadcrumbItem[]>([]);
const [showHiddenDropdown, setShowHiddenDropdown] = useState<boolean>(false);
const [currentPath, setCurrentPath] = useState("");
const [collapsed, setCollapsed] = useState<boolean>(collapsible);
const prefersReducedMotion =
typeof window !== "undefined"
? window.matchMedia("(prefers-reduced-motion: reduce)").matches
: false;
// Use explicit user preference if provided, otherwise respect system preference
const shouldDisableAnimations =
noAnimations !== undefined ? noAnimations : prefersReducedMotion;
const parentVariants = {
animate: {
transition: {
staggerChildren: 0.05,
ease: "easeOut",
duration: 0.2,
},
},
};
const childVariants = {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
transition: { ease: "easeOut", duration: 0.2 },
};
const checkIfCurrentPath = useCallback(
(href: string) => {
// Normalize paths by removing trailing slashes and ensuring they start with /
const normalizedCurrentPath =
currentPath === "" ? "/" : currentPath.replace(/\/$/, "");
const normalizedHref = href.replace(/\/$/, "") || "/";
// Special case for home button
if (normalizedHref === homeHref || normalizedHref === "/") {
return (
normalizedCurrentPath === "/" || normalizedCurrentPath === homeHref
);
}
// For other paths, check if they match exactly
return normalizedCurrentPath === normalizedHref;
},
[currentPath, homeHref]
);
const generateCrumbsFromUrl = useCallback(() => {
if (mode === "url") {
const pathParts = currentPath.split("/").filter(Boolean);
const newItems: BreadcrumbItem[] = pathParts.map((part, index) => {
const href = `/${pathParts.slice(0, index + 1).join("/")}`;
return {
label: part.charAt(0).toUpperCase() + part.slice(1),
href,
icon: null,
};
});
// Limit the number of items based on maxItems
if (newItems.length > maxItems) {
const startIndex = newItems.length - maxItems;
const newItemsToShow = newItems.slice(startIndex);
setVisibleItems(newItemsToShow);
const hiddenItemsReversed = newItems.slice(0, startIndex).reverse();
setHiddenItems(hiddenItemsReversed);
} else {
setVisibleItems(newItems);
}
} else if (mode === "custom" && items?.length) {
setVisibleItems(items);
}
}, [mode, currentPath, maxItems, items]);
useEffect(() => {
if (typeof window !== "undefined") {
setCurrentPath(window.location.pathname);
}
if (mode === "url") {
generateCrumbsFromUrl();
} else if (mode === "custom" && items?.length) {
setVisibleItems(items);
}
}, [mode, generateCrumbsFromUrl, items]);
return (
<nav
className={cn("w-fit", navClassName)}
aria-label="Page navigation breadcrumb"
>
<motion.div
className="flex gap-1 relative"
initial="initial"
animate="animate"
variants={shouldDisableAnimations ? undefined : parentVariants}
>
{collapsible &&
visibleItems.length >= 1 &&
!checkIfCurrentPath(homeHref) ? (
<motion.button
className="text-gray-500 hover:text-blue-800 transition-colors duration-200 cursor-pointer flex items-center justify-center dark:text-white/70 hover:dark:text-slate-400"
onClick={() => {
setCollapsed(!collapsed);
setShowHiddenDropdown(false);
}}
aria-label={collapsed ? "Expand breadcrumb" : "Collapse breadcrumb"}
whileTap={noAnimations ? undefined : { scale: 0.95 }}
>
<motion.div
animate={
noAnimations ? undefined : { rotate: collapsed ? 90 : 0 }
}
transition={
noAnimations ? undefined : { duration: 0.1, ease: "easeOut" }
}
className={`inline-block ml-1 ${
noAnimations && collapsed ? "rotate-90" : ""
}`}
>
<ChevronRight />
</motion.div>
</motion.button>
) : null}
{showHome ? (
<motion.a
className={cn(
"text-gray-500 hover:text-gray-900 transition-colors duration-200 overflow-auto whitespace-nowrap flex items-center justify-center dark:text-white/80 hover:dark:text-white/80",
variant === "pills" &&
"rounded-full border dark:border-gray-600 px-3 py-1",
variant === "bordered" &&
"rounded-sm border dark:border-gray-600 px-3 py-1"
)}
href={homeHref}
onClick={() => {
if (onItemClick) {
onItemClick({ label: "Home", href: homeHref }, 0);
}
}}
>
<Home className=" size-4 dark:text-white/60 hover:dark:text-white/50" />
{homeLabel ? <span className="ml-1">{homeLabel}</span> : null}
</motion.a>
) : null}
{/* Home separator - only show if home is visible and there are visible items */}
{showHome && visibleItems.length > 0 && variant === "default" ? (
<div className="flex items-center justify-center h-full text-gray-400 size-4">
{separator === "chevron" && <ChevronRight />}
{separator === "slash" && <span>/</span>}
{separator === "line" && <LineSeparator />}
{separator === "custom" && customSeparator}
</div>
) : null}
{!collapsed && hiddenItems.length > 0 && (
<motion.div
className="flex items-center gap-1"
variants={shouldDisableAnimations ? undefined : childVariants}
>
<span
className="text-gray-400 font-bold cursor-pointer hover:text-blue-800 transition-colors duration-200 dark:text-white/70 hover:dark:text-slate-400"
onClick={() => setShowHiddenDropdown(!showHiddenDropdown)}
aria-expanded={showHiddenDropdown}
>
...
</span>
{showHiddenDropdown ? (
<motion.div
variants={shouldDisableAnimations ? undefined : parentVariants}
className={cn(
"absolute top-6 left-0 bg-white rounded-sm z-10 mt-1 dark:bg-transparent",
variant !== "default" && "mt-6",
variant === "bordered" &&
"rounded-sm border dark:border-gray-600 px-3 py-1 mt-5"
)}
>
{hiddenItems.map((item, index) => (
<motion.div
variants={
shouldDisableAnimations ? undefined : childVariants
}
className="flex flex-row items-center"
key={index}
>
{variant === "default" ? (
<div>
<div className="flex items-center justify-center h-full text-gray-400 size-4">
{separator === "chevron" && <ChevronRight />}
{separator === "slash" && <span>/</span>}
{separator === "line" && <LineSeparator />}
{separator === "custom" && customSeparator}
</div>
</div>
) : null}
<div className="flex flex-col align-center justify-start gap-2 w-full h-full text-base">
{item.href && !checkIfCurrentPath(item.href) ? (
<motion.a
className={cn(
"text-gray-500 hover:text-gray-900 transition-colors duration-200 overflow-auto whitespace-nowrap dark:text-white/90 hover:dark:text-white/80",
variant === "pills" &&
"rounded-full border dark:border-gray-600 px-3 py-1 mt-2"
)}
href={item.href}
whileHover={
shouldDisableAnimations
? undefined
: {
transition: { duration: 0.1 },
}
}
onClick={() => {
if (onItemClick) {
onItemClick(item, index);
}
}}
>
{item.label}
</motion.a>
) : (
<span
className={cn(
"overflow-auto whitespace-nowrap",
checkIfCurrentPath(item.href || "")
? "text-blue-600 font-semibold"
: ""
)}
onClick={() => {
if (onItemClick) {
onItemClick(item, index);
}
}}
aria-current={
checkIfCurrentPath(item.href || "")
? "page"
: undefined
}
>
{item.label}
</span>
)}
</div>
</motion.div>
))}
</motion.div>
) : null}
</motion.div>
)}
{!collapsed &&
visibleItems.map((item, index) => {
return (
<motion.div
key={index}
className={cn("flex items-center", index !== 0 && "gap-1")}
variants={shouldDisableAnimations ? undefined : childVariants}
>
<div>
{index !== 0 && (
<div className="flex items-center justify-center h-full text-gray-400 size-4 dark:text-white/70">
{separator === "chevron" && <ChevronRight />}
{separator === "slash" && <span>/</span>}
{separator === "line" && <LineSeparator />}
{separator === "custom" && customSeparator}
</div>
)}
</div>
<motion.div
key={index}
className="flex items-center justify-center gap-1"
>
{/* LOGO */}
{item.icon ? (
<span className="text-slate-400 dark:text-white/80">
{item.icon}
</span>
) : null}
{/* LINK */}
{item.href && !checkIfCurrentPath(item.href) ? (
<motion.a
className={cn(
"text-gray-500 hover:text-gray-900 transition-colors duration-200 overflow-auto whitespace-nowrap dark:text-white/90 hover:dark:text-white/80",
variant === "pills" &&
"rounded-full border dark:border-gray-600 px-3 py-1",
variant === "bordered" &&
"rounded-sm border dark:border-gray-600 px-3 py-1",
crumbClassNames
)}
href={item.href}
whileHover={
shouldDisableAnimations
? undefined
: {
transition: { duration: 0.1 },
}
}
onClick={() => {
if (onItemClick) {
onItemClick(item, index);
}
}}
>
{item.label}
</motion.a>
) : (
<span
className={cn(
"overflow-auto whitespace-nowrap",
checkIfCurrentPath(item.href || "")
? "text-blue-600 font-semibold dark:text-blue-400"
: "",
variant === "pills" &&
"rounded-full border dark:border-gray-600 px-3 py-1",
variant === "bordered" &&
"rounded-sm border dark:border-gray-600 px-3 py-1",
crumbClassNames
)}
onClick={() => {
if (onItemClick) {
onItemClick(item, index);
}
}}
aria-current={
checkIfCurrentPath(item.href || "") ? "page" : undefined
}
>
{item.label}
</span>
)}
</motion.div>
</motion.div>
);
})}
</motion.div>
</nav>
);
};
For a full list of props - see the github link.