Setup lists the required files and packages. Usage shows how to add the component.
BrowserCrumbsDemo.jsx
"use client";
import { useCallback, useEffect, useId, useLayoutEffect, useReducer, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { usePrefersReducedMotion } from "./usePrefersReducedMotion";
import "./BrowserCrumbsDemo.css";
const MAX_LEVELS = 4;
const NEXT_OPTIONS = [
["Second Page", "Alt Page B", "Alt Page C"],
["Third Page", "Detour Page", "Alt Page 2C"],
["Fourth Page", "Alt Page 3B", "Archive"],
[]
];
function initialHistory(compact) {
return { trail: compact ? ["Main Page", "Second Page"] : ["Main Page"], past: compact ? [["Main Page"]] : [], future: [] };
}
function historyReducer(state, action) {
if (action.type === "reset") return initialHistory(action.compact);
if (action.type === "back") {
if (!state.past.length) return state;
return { trail: state.past[state.past.length - 1], past: state.past.slice(0, -1), future: [state.trail, ...state.future] };
}
if (action.type === "forward") {
if (!state.future.length) return state;
return { trail: state.future[0], past: [...state.past, state.trail], future: state.future.slice(1) };
}
if (action.type === "pick" && (state.trail.length >= MAX_LEVELS || !NEXT_OPTIONS[state.trail.length - 1]?.includes(action.label))) return state;
const trail = action.type === "pick" ? [...state.trail, action.label] : state.trail.slice(0, action.index + 1);
if (trail.length === state.trail.length) return state;
return { trail, past: [...state.past, state.trail], future: [] };
}
function ArrowGlyph({ forward = false }) {
return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d={forward ? "M5 12h14m-7-7 7 7-7 7" : "M19 12H5m7-7-7 7 7 7"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>;
}
function ChevronGlyph() {
return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m6 9 6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>;
}
/**
* An in-memory navigation demo, not a router. Place it inside a sized container.
* `compact` starts one level deep; `reducedMotion` can disable all transitions.
* Optional `historyRef` retains history across remounts, and `controls` receives
* reset/replay methods while this instance is mounted.
*/
function BrowserCrumbsDemo({ compact = false, controls, reducedMotion = false, historyRef }) {
const [{ trail, past, future }, dispatch] = useReducer(
historyReducer, compact, (isCompact) => historyRef?.current ?? initialHistory(isCompact)
);
// Retain committed navigation when the portfolio moves the single preview
// between its embedded and fullscreen containers. Replay clears this ref.
useLayoutEffect(() => {
if (historyRef) historyRef.current = { trail, past, future };
}, [historyRef, trail, past, future]);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const [geometry, setGeometry] = useState({ x: 0, y: 36, width: 164, height: 114, triggerWidth: 82, triggerHeight: 28, rowHeight: 34 });
const rootRef = useRef(null);
const triggerRef = useRef(null);
const menuRef = useRef(null);
const optionRefs = useRef([]);
const timers = useRef([]);
const entryFocus = useRef(null);
const restoreFocus = useRef(false);
const typeahead = useRef({ text: "", time: 0 });
const id = useId();
const prefersReducedMotion = usePrefersReducedMotion();
const reduced = reducedMotion || prefersReducedMotion;
const options = NEXT_OPTIONS[trail.length - 1] ?? [];
const label = trail[trail.length - 1];
const duration = reduced ? 0 : 0.32;
const labelDuration = reduced ? 0 : 0.2;
const clearTimers = useCallback(() => {
timers.current.forEach(window.clearTimeout);
timers.current = [];
}, []);
const closeMenu = useCallback((restore = false) => {
setOpen(false);
setActive(-1);
typeahead.current = { text: "", time: 0 };
if (restore) triggerRef.current?.focus({ preventScroll: true });
}, []);
const measure = useCallback(() => {
const root = rootRef.current;
const trigger = triggerRef.current;
if (!root || !trigger) return;
const stage = root.getBoundingClientRect();
const anchor = trigger.getBoundingClientRect();
const rowHeight = stage.height < 200 ? 28 : 34;
const width = Math.min(164, stage.width - 16);
const height = Math.min(Math.max(options.length, 1) * rowHeight + 12, stage.height - 16);
const left = Math.max(stage.left + 8, Math.min(anchor.left - 6, stage.right - width - 8));
const below = anchor.bottom + 8;
const above = anchor.top - height - 8;
const top = below + height <= stage.bottom - 8 ? below : above >= stage.top + 8 ? above : Math.max(stage.top + 8, stage.bottom - height - 8);
setGeometry({ x: left - anchor.left, y: top - anchor.top, width, height, triggerWidth: anchor.width, triggerHeight: anchor.height, rowHeight });
}, [options.length]);
useLayoutEffect(() => {
measure();
if (restoreFocus.current) {
triggerRef.current?.focus({ preventScroll: true });
restoreFocus.current = false;
}
}, [trail, measure]);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, [measure]);
useLayoutEffect(() => {
if (!open || entryFocus.current === null) return;
optionRefs.current[entryFocus.current]?.focus({ preventScroll: true });
optionRefs.current[entryFocus.current]?.scrollIntoView({ block: "nearest" });
entryFocus.current = null;
}, [open]);
const openMenu = (index = null) => {
measure();
if (menuRef.current) menuRef.current.scrollTop = 0;
typeahead.current = { text: "", time: 0 };
setActive(index ?? -1);
entryFocus.current = index;
setOpen(true);
if (open && index !== null) optionRefs.current[index]?.focus({ preventScroll: true });
};
const navigate = (action, restore = false) => {
clearTimers();
closeMenu();
restoreFocus.current = restore;
dispatch(action);
};
const reset = useCallback(() => {
clearTimers();
closeMenu();
dispatch({ type: "reset", compact });
}, [clearTimers, closeMenu, compact]);
useEffect(() => {
if (!controls) return;
controls.reset = reset;
controls.replay = () => {
reset();
dispatch({ type: "reset", compact: false });
if (reduced) return;
const later = (callback, delay) => timers.current.push(window.setTimeout(callback, delay));
["Second Page", "Third Page", "Fourth Page"].forEach((page, index) => {
later(() => {
setActive(0);
setOpen(true);
}, 350 + index * 1300);
later(() => {
closeMenu();
dispatch({ type: "pick", label: page });
}, 1100 + index * 1300);
});
};
return () => {
delete controls.reset;
delete controls.replay;
clearTimers();
};
}, [controls, reset, reduced, clearTimers, closeMenu]);
useEffect(() => () => clearTimers(), [clearTimers]);
useEffect(() => {
if (!open) return;
const dismiss = (event) => {
const target = event.target;
if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) closeMenu();
};
document.addEventListener("pointerdown", dismiss);
return () => document.removeEventListener("pointerdown", dismiss);
}, [open, closeMenu]);
const focusOption = (index) => {
setActive(index);
optionRefs.current[index]?.focus({ preventScroll: true });
optionRefs.current[index]?.scrollIntoView({ block: "nearest" });
};
const onKeyDown = (event) => {
if (event.key === "Escape" && open) {
event.preventDefault();
event.stopPropagation();
closeMenu(true);
return;
}
if (event.key === "Tab") {
if (open) closeMenu(true);
return;
}
const keys = ["ArrowDown", "ArrowUp", "Home", "End"];
if (keys.includes(event.key) && options.length) {
event.preventDefault();
const focused = optionRefs.current.findIndex((item) => item === document.activeElement);
const index = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : event.key === "ArrowDown" ? (focused + 1) % options.length : focused <= 0 ? options.length - 1 : focused - 1;
if (!open) openMenu(index);
else focusOption(index);
return;
}
if (!open || event.key.length !== 1 || event.key === " " || event.altKey || event.metaKey || event.ctrlKey) return;
event.preventDefault();
const now = performance.now();
let text = now - typeahead.current.time < 650 ? typeahead.current.text + event.key.toLowerCase() : event.key.toLowerCase();
if ([...text].every((letter) => letter === text[0])) text = text[0];
typeahead.current = { text, time: now };
const start = Math.max(0, active + 1);
for (let offset = 0; offset < options.length; offset++) {
const index = (start + offset) % options.length;
if (options[index].toLowerCase().startsWith(text)) {
focusOption(index);
break;
}
}
};
const style = {
"--bc-ms": `${reduced ? 0 : 480}ms`,
"--bc-row-height": `${geometry.rowHeight}px`,
"--bc-panel-width": `${geometry.width}px`,
"--bc-panel-height": `${geometry.height}px`,
"--bc-panel-x": `${geometry.x}px`,
"--bc-panel-y": `${geometry.y}px`,
"--bc-trigger-width": `${geometry.triggerWidth}px`,
"--bc-trigger-height": `${geometry.triggerHeight}px`
};
return <div
ref={rootRef}
className={`bc-demo ${compact ? "bc-demo--compact" : ""}`}
data-open={open}
data-label-motion="crossfade"
data-reduced={reduced}
data-menu-overlap={geometry.y < geometry.triggerHeight && geometry.y + geometry.height > 0}
style={style}
onPointerDownCapture={clearTimers}
onKeyDownCapture={clearTimers}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) closeMenu();
}}
onClick={compact ? (event) => {
event.preventDefault();
event.stopPropagation();
} : void 0}
>
<nav className="bc-bar" aria-label="Breadcrumb">
<div className="bc-arrows">
<button
type="button"
className="bc-arrow"
aria-label="Back"
disabled={!past.length}
onClick={() => navigate({ type: "back" }, past.length === 1)}
><ArrowGlyph /></button>
<button
type="button"
className="bc-arrow"
aria-label="Forward"
disabled={!future.length}
onClick={() => navigate({ type: "forward" }, future.length === 1)}
><ArrowGlyph forward /></button>
</div>
<ol className="bc-trail">
{trail.slice(0, -1).map((page, index) => <motion.li layout="position" transition={{ duration }} className="bc-crumb" data-last="false" key={`${index}:${page}`}>
{index > 0 && <span className="bc-sep" aria-hidden="true">/</span>}
<button type="button" className="bc-mid" title={page} onClick={() => navigate({ type: "ancestor", index }, true)}>
<span className="bc-text">{page}</span>
</button>
</motion.li>)}
<motion.li layout="position" transition={{ duration }} onLayoutAnimationComplete={measure} className="bc-crumb" data-last="true" key="current">
{trail.length > 1 && <span className="bc-sep" aria-hidden="true">/</span>}
<div className="bc-last" onKeyDown={onKeyDown}>
<span className="bc-morph-shape" aria-hidden="true" />
<button
ref={triggerRef}
id={`${id}-trigger`}
type="button"
className="bc-current"
aria-current="page"
aria-label={label}
aria-expanded={open}
aria-haspopup="menu"
aria-controls={`${id}-menu`}
onClick={(event) => open ? closeMenu(true) : openMenu(event.detail === 0 && options.length ? 0 : null)}
>
<span className="bc-value" aria-hidden="true">
<span className="bc-value-sizer">{label}</span>
<AnimatePresence initial={false}>
<motion.span
key={label}
className="bc-value-face"
initial={{ y: 0, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 0, opacity: 0, transition: { duration: reduced ? 0 : 0.08, ease: [0.22, 1, 0.36, 1] } }}
transition={{ duration: labelDuration, ease: [0.22, 1, 0.36, 1] }}
>{label}</motion.span>
</AnimatePresence>
</span>
<ChevronGlyph />
</button>
<div ref={menuRef} id={`${id}-menu`} className="bc-menu" role="menu" aria-label={`Pages inside ${label}`} aria-hidden={!open} inert={!open}>
<span className="bc-highlight" aria-hidden="true" style={{ opacity: active < 0 ? 0 : 1, transform: `translateY(${Math.max(0, active) * geometry.rowHeight}px)` }} />
{options.length ? options.map((option, index) => <button
key={option}
ref={(element) => {
optionRefs.current[index] = element;
}}
type="button"
role="menuitem"
className="bc-option"
tabIndex={open && active === index ? 0 : -1}
style={{ "--bc-index": index }}
onPointerMove={() => setActive(index)}
onFocus={() => setActive(index)}
onClick={() => navigate({ type: "pick", label: option }, true)}
><span>{option}</span></button>) : <span role="menuitem" aria-disabled="true" className="bc-option bc-option--empty">No more pages</span>}
</div>
</div>
</motion.li>
</ol>
</nav>
<span className="bc-status" role="status" aria-live="polite" aria-atomic="true">{trail.join(" / ")}{trail.length === MAX_LEVELS ? ". Last page level." : ""}</span>
</div>;
}
export {
BrowserCrumbsDemo
};