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.
Props & Styling
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | BreadcrumbItem[] ({ id, title, link }) | — (required) | Full path, from the root to the current page. |
maxItems | number | 5 (clamped to a minimum of 3) | Once items.length exceeds this, the middle items collapse behind an ellipsis menu. |
separator | ReactNode | <ChevronRight /> | Custom divider rendered between crumbs. |
renderItem | (item: BreadcrumbItem) => ReactNode | plain <a href={item.link}> | Override how each crumb renders — the demo on this page uses it to render static <span>s instead of real links. |
Styling
When collapsing, the first item and the last maxItems - 2 items stay visible; everything between them collapses into the ellipsis button. Clicking the ellipsis opens CollapsedMenu in an absolutely-positioned dropdown, animated with opacity/scale: 0.95 → 1/y: -2 → 0 at 0.2s easeOut — deliberately kept subtle since a breadcrumb is a navigation aid, not a focal interaction. The dropdown closes on any click outside it, tracked via a window click listener rather than a blur handler, so it also closes when focus moves outside the DOM entirely.
Variants
Short path (no collapse)
Under maxItems, every crumb renders — no ellipsis.
Custom separator
The separator prop accepts any ReactNode in place of the default chevron.
Long path (collapsed)
Exceeding maxItems collapses the middle items into the ellipsis menu.
Code
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