Dropdown Breadcrumb
Original · freebreadcrumb with a dropdown level
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/crumb-dropdown.json"use client";
import {
useEffect,
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Section = {
id: string;
label: string;
summary: string;
pages: string[];
};
const SECTIONS: Section[] = [
{
id: "navigation",
label: "Navigation",
summary: "Wayfinding patterns — menus, tabs, breadcrumbs and pagination.",
pages: ["Breadcrumb", "Tabs", "Pagination", "Command Menu"],
},
{
id: "data-display",
label: "Data Display",
summary: "Presenting records — tables, stat cards, badges and timelines.",
pages: ["Data Table", "Stat Card", "Badge", "Timeline"],
},
{
id: "feedback",
label: "Feedback",
summary: "System response — toasts, alerts, progress and skeletons.",
pages: ["Toast", "Alert", "Progress", "Skeleton"],
},
{
id: "overlays",
label: "Overlays",
summary: "Layered surfaces — dialogs, popovers, drawers and tooltips.",
pages: ["Dialog", "Popover", "Drawer", "Tooltip"],
},
{
id: "forms",
label: "Forms",
summary: "Data entry and validation — fields, selects, switches and comboboxes.",
pages: ["Text Field", "Select", "Switch", "Combobox"],
},
];
function HomeIcon() {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-4 w-4"
>
<path
d="M3.5 9.5 10 4l6.5 5.5M5 8.5V16h3.25v-3.75h3.5V16H15V8.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronRight() {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-3.5 w-3.5"
>
<path
d="M7.75 4.5 13.25 10l-5.5 5.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function ChevronDown() {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-3.5 w-3.5"
>
<path
d="M5.5 8 10 12.5 14.5 8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function CheckIcon() {
return (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
className="h-4 w-4"
>
<path
d="m5 10.5 3.25 3.25L15 6.5"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function CrumbDropdown() {
const reduce = useReducedMotion();
const baseId = useId();
const triggerId = `${baseId}-trigger`;
const menuId = `${baseId}-menu`;
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const [focusIndex, setFocusIndex] = useState(0);
const [currentPage, setCurrentPage] = useState(SECTIONS[0].pages[0]);
const rootRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const activeSection = SECTIONS[activeIndex];
useEffect(() => {
if (!open) return;
itemRefs.current[focusIndex]?.focus();
}, [open, focusIndex]);
useEffect(() => {
if (!open) return;
function handlePointer(event: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("pointerdown", handlePointer);
return () => document.removeEventListener("pointerdown", handlePointer);
}, [open]);
function openMenu(index: number) {
setFocusIndex(index);
setOpen(true);
}
function closeMenu(returnFocus: boolean) {
setOpen(false);
if (returnFocus) triggerRef.current?.focus();
}
function toggleMenu() {
if (open) setOpen(false);
else openMenu(activeIndex);
}
function selectSection(index: number) {
setActiveIndex(index);
setCurrentPage(SECTIONS[index].pages[0]);
closeMenu(true);
}
function onTriggerKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>) {
if (event.key === "ArrowDown") {
event.preventDefault();
openMenu(activeIndex);
} else if (event.key === "ArrowUp") {
event.preventDefault();
openMenu(SECTIONS.length - 1);
}
}
function onMenuKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
const last = SECTIONS.length - 1;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setFocusIndex((i) => (i >= last ? 0 : i + 1));
break;
case "ArrowUp":
event.preventDefault();
setFocusIndex((i) => (i <= 0 ? last : i - 1));
break;
case "Home":
event.preventDefault();
setFocusIndex(0);
break;
case "End":
event.preventDefault();
setFocusIndex(last);
break;
case "Escape":
event.preventDefault();
closeMenu(true);
break;
case "Tab":
setOpen(false);
break;
default:
break;
}
}
const crumbLink =
"inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-slate-500 transition-colors hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:text-white dark:focus-visible:ring-offset-slate-900";
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-6 py-20 dark:from-slate-950 dark:to-slate-900 sm:px-8">
<style>{`
@keyframes crmbdd-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.65); }
}
@keyframes crmbdd-ring {
0% { transform: scale(0.6); opacity: 0.55; }
70% { transform: scale(2.1); opacity: 0; }
100% { transform: scale(2.1); opacity: 0; }
}
.crmbdd-dot { animation: crmbdd-pulse 2.6s ease-in-out infinite; }
.crmbdd-ring { animation: crmbdd-ring 2.6s ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.crmbdd-dot, .crmbdd-ring { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-64 w-[36rem] -translate-x-1/2 rounded-full bg-indigo-200/40 blur-3xl dark:bg-indigo-600/10"
/>
<div className="relative mx-auto w-full max-w-3xl">
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Aurora UI · Reference
</p>
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900/60 sm:p-7">
<nav aria-label="Breadcrumb">
<ol className="flex flex-wrap items-center gap-x-1 gap-y-1.5 text-sm font-medium">
<li>
<a href="#home" className={crumbLink}>
<HomeIcon />
<span className="sr-only sm:not-sr-only">Home</span>
</a>
</li>
<li aria-hidden="true" className="text-slate-300 dark:text-slate-600">
<ChevronRight />
</li>
<li>
<a href="#components" className={crumbLink}>
Components
</a>
</li>
<li aria-hidden="true" className="text-slate-300 dark:text-slate-600">
<ChevronRight />
</li>
<li>
<div ref={rootRef} className="relative">
<button
id={triggerId}
ref={triggerRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
aria-label={`Section: ${activeSection.label}. Choose another section`}
onClick={toggleMenu}
onKeyDown={onTriggerKeyDown}
className={
"inline-flex items-center gap-1 rounded-md px-2 py-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 " +
(open
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300"
: "text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-white")
}
>
{activeSection.label}
<motion.span
className="inline-flex text-slate-400 dark:text-slate-500"
animate={{ rotate: open ? 180 : 0 }}
transition={{ duration: reduce ? 0 : 0.2, ease: [0.16, 1, 0.3, 1] }}
>
<ChevronDown />
</motion.span>
</button>
<AnimatePresence>
{open && (
<motion.div
id={menuId}
role="menu"
aria-labelledby={triggerId}
aria-orientation="vertical"
onKeyDown={onMenuKeyDown}
initial={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.97 }}
transition={{ duration: reduce ? 0 : 0.16, ease: [0.16, 1, 0.3, 1] }}
className="absolute left-0 top-full z-20 mt-2 w-72 origin-top-left rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-900/10 ring-1 ring-slate-900/5 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/40 dark:ring-white/10"
>
<p className="px-2 pb-1.5 pt-1 text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
Jump to section
</p>
<ul className="flex flex-col gap-0.5">
{SECTIONS.map((section, i) => {
const selected = i === activeIndex;
return (
<li key={section.id} role="none">
<button
ref={(el) => {
itemRefs.current[i] = el;
}}
type="button"
role="menuitemradio"
aria-checked={selected}
tabIndex={i === focusIndex ? 0 : -1}
onClick={() => selectSection(i)}
onMouseEnter={() => setFocusIndex(i)}
className={
"group flex w-full items-start gap-3 rounded-lg px-2.5 py-2 text-left transition-colors focus:outline-none " +
(i === focusIndex
? "bg-indigo-50 dark:bg-indigo-500/10"
: "hover:bg-slate-50 dark:hover:bg-slate-800/60")
}
>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-slate-900 dark:text-white">
{section.label}
</span>
<span className="mt-0.5 block text-xs leading-snug text-slate-500 dark:text-slate-400">
{section.summary}
</span>
</span>
<span
aria-hidden={!selected}
className={
"mt-0.5 shrink-0 text-emerald-500 transition-opacity dark:text-emerald-400 " +
(selected ? "opacity-100" : "opacity-0")
}
>
<CheckIcon />
</span>
</button>
</li>
);
})}
</ul>
</motion.div>
)}
</AnimatePresence>
</div>
</li>
<li aria-hidden="true" className="text-slate-300 dark:text-slate-600">
<ChevronRight />
</li>
<li>
<span
aria-current="page"
className="rounded-md px-1.5 py-1 font-semibold text-slate-900 dark:text-white"
>
{currentPage}
</span>
</li>
</ol>
</nav>
<div className="my-6 h-px w-full bg-slate-200 dark:bg-slate-800" />
<div>
<div className="flex items-center gap-2.5">
<span className="relative inline-flex h-2.5 w-2.5 items-center justify-center">
<span className="crmbdd-ring absolute inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500/60" />
<span className="crmbdd-dot relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
</span>
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
{activeSection.label}
</h2>
</div>
<p className="mt-1.5 text-sm text-slate-600 dark:text-slate-300">
{activeSection.summary}
</p>
<div className="mt-4 flex flex-wrap gap-2" role="group" aria-label={`Pages in ${activeSection.label}`}>
{activeSection.pages.map((page) => {
const isCurrent = page === currentPage;
return (
<button
key={page}
type="button"
aria-pressed={isCurrent}
onClick={() => setCurrentPage(page)}
className={
"rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 " +
(isCurrent
? "border-indigo-500 bg-indigo-500 text-white dark:border-indigo-400 dark:bg-indigo-500"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white")
}
>
{page}
</button>
);
})}
</div>
</div>
</div>
<p className="mt-4 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
Focus the
<span className="font-medium text-slate-700 dark:text-slate-200">{activeSection.label}</span>
crumb, then press
<kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-sans text-[11px] font-medium text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200">
↓
</kbd>
to browse sections and
<kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-sans text-[11px] font-medium text-slate-700 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200">
Esc
</kbd>
to close.
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Breadcrumb
Originalbasic breadcrumb trail

Slash Breadcrumb
Originalslash separator breadcrumbs

Chevron Breadcrumb
Originalchevron separator breadcrumbs

Arrow Breadcrumb
Originalarrow-chip breadcrumbs

Pill Breadcrumb
Originalpill breadcrumbs
Icons Breadcrumb
Originalbreadcrumbs with icons

Collapsed Breadcrumb
Originalcollapsed breadcrumbs with ellipsis

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

