Breadcrumb
This component can be configured based on the max items, icons, items, and render item props. The only animations present are hover animations and the opening of the collapsed menu through the ellipsis since this component primarily serves as navigation, animations are at a minimum here. Given the nature of the ellipsis button opening a menu, that component is only rendered if the length of items is greater than the enforced max items coupled with an ease out animation that feels on par to the feeling of opening something on your phone.
Code
// Breadcrumb.tsx
import React from 'react'
import { ChevronRight, Ellipsis } from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import CollapsedMenu from './CollapsedMenu'
import type { BreadcrumbItem } from './types'
import { AnimatePresence, motion } from 'motion/react'
import BreadcrumbStandard from './BreadcrumbStandard'
import BreadcrumbStart from './BreadcrumbStart'
import BreadcrumbEnd from './BreadcrumbEnd'
interface BreadCrumbProps {
items: BreadcrumbItem[]
maxItems?: number
separator?: React.ReactNode
renderItem?:(item:BreadcrumbItem) => React.ReactNode
}
const Breadcrumb = ({items, maxItems = 5, separator, renderItem} : BreadCrumbProps) => {
const [isExpanded, setIsExpanded] = useState<boolean>(false);
const elipsisRef = useRef<HTMLButtonElement>(null);
const enforcedMaxItems = Math.max(maxItems, 3);
const listItemCount = enforcedMaxItems - 2;
let visibleStart: BreadcrumbItem[] = [];
let elipsesItems: BreadcrumbItem[] = [];
let visibleEnd: BreadcrumbItem[] = [];
if(items.length > enforcedMaxItems) {
visibleStart = items.slice(0, 1);
visibleEnd = items.slice(items.length - listItemCount, items.length);
elipsesItems = items.slice(1, items.length - listItemCount)
}
const handleButtonClick = () => {
setIsExpanded(prev => !prev)
}
useEffect(() => {
const clickObserver = (e:MouseEvent) => {
if(!isExpanded) return;
if(e.target instanceof Node){
if(elipsisRef.current && !elipsisRef.current.contains(e.target)){
setIsExpanded(false)
}
}
}
window.addEventListener('click', clickObserver)
return () => {
window.removeEventListener('click', clickObserver)
}
}, [isExpanded])
return (
<nav>
<ul className='flex flex-row flex-wrap items-center gap-1'>
{items.length <= enforcedMaxItems ? (
<BreadcrumbStandard
items = {items}
renderItem={renderItem}
separator = {separator}
/>
) : (
<>
<BreadcrumbStart
startItems={visibleStart}
renderItem = {renderItem}
separator = {separator}
/>
<li className = "relative flex flex-row items-center gap-1 text-gray-500 hover:text-gray-700">
<button
ref = {elipsisRef}
aria-label = "Show missing paths"
onClick = {handleButtonClick}
className = "p-1 rounded-md hover:bg-gray-200/70"
>
<Ellipsis className = "text-gray-500" size = {'16px'}/>
</button>
<AnimatePresence mode='popLayout'>
{isExpanded === true && (
<motion.div
className = "absolute flex flex-col w-48 gap-1 bg-white border border-gray-200 rounded-md shadow-sm top-7"
initial = {{opacity: 0, scale: 0.95, y: -2}}
animate = {{opacity: 1, scale: 1, y: 0}}
exit = {{opacity: 0, scale: 0.95, y: -2}}
transition={{ease: 'easeOut', duration: 0.2}}
>
<CollapsedMenu
collapsedList={elipsesItems}
renderItem={renderItem}
/>
</motion.div>
)}
</AnimatePresence>
{separator ? separator : <ChevronRight size = {'16px'} className = "text-gray-400"/>}
</li>
<BreadcrumbEnd
endItems = {visibleEnd}
renderItem={renderItem}
separator = {separator}
/>
</>
)}
</ul>
</nav>
)
}
export default Breadcrumb
// BreadcrumbStart.tsx
import React from 'react'
import type { BreadcrumbItem } from './types'
import { ChevronRight } from 'lucide-react'
interface BreadcrumbStartProps {
startItems: BreadcrumbItem[]
renderItem?: (item:BreadcrumbItem) => React.ReactNode
separator?: React.ReactNode
}
const BreadcrumbStart = ({startItems, renderItem, separator}: BreadcrumbStartProps) => {
return (
<>
{startItems.map((start) =>
<li key = {start.id} className = "flex flex-row items-center gap-1 text-gray-500 hover:text-gray-700">
{renderItem ? renderItem(start) : <a href = {start.link}>{start.title}</a>}
{separator ? separator : <ChevronRight size = {'16px'} className = "text-gray-500"/>}
</li>
)}
</>
)
}
export default BreadcrumbStart
// BreadcrumbStandard.tsx
import React from 'react'
import type { BreadcrumbItem } from './types'
import { ChevronRight } from 'lucide-react'
interface breadcrumbStandardProps{
items: BreadcrumbItem[]
renderItem?: (item:BreadcrumbItem) => React.ReactNode
separator?: React.ReactNode
}
const BreadcrumbStandard = ({items, renderItem, separator}: breadcrumbStandardProps) => {
return (
<>
{items.map((item, index) => {
if(index === items.length - 1){
return(
<li key = {item.id} aria-current ="page" className = "text-gray-700">
{item.title}
</li>
)
}
return (
<li key = {item.id} className = "flex flex-row items-center gap-1 text-gray-500 hover:text-gray-700">
{renderItem ? renderItem(item) : <a href = {item.link}>{item.title}</a>}
{separator ? separator : <ChevronRight size = {'16px'} className = "text-gray-500"/>}
</li>
)
})}
</>
)
}
export default BreadcrumbStandard
// BreadcrumbEnd.tsx
import type { BreadcrumbItem } from "./types"
import { ChevronRight } from "lucide-react"
interface BreadcrumbEndProps {
endItems: BreadcrumbItem[]
renderItem?: (item:BreadcrumbItem) => React.ReactNode
separator?: React.ReactNode
}
const BreadcrumbEnd = ({endItems, renderItem, separator}: BreadcrumbEndProps) => {
return (
<>
{endItems.map((endItem, index) => {
if (index === endItems.length - 1){
return(
<li key = {endItem.id} aria-current ="page" className = "flex flex-row items-center gap-1 text-gray-700">
{endItem.title}
</li>
)
}
return (
<li key = {endItem.id}>
{renderItem ? renderItem(endItem) : <a href = {endItem.link}>{endItem.title}</a>}
{separator ? separator : <ChevronRight size = {'16px'} className = "text-gray-400"/>}
</li>
)
})}
</>
)
}
export default BreadcrumbEnd