Breadcrumbs
A path of crumbs split by thin slashes, the current page in full text colour and not a link.
import { Fragment, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent, MouseEvent } from 'react';
import { createLoop } from '../shared/spring';
import { resolveAccent, type Accent } from '../shared/accent';
import { usePop } from '../display-kit/motion';
import { Highlight } from '../display-kit/highlight';
import { placeVertical } from '../display-kit/place';
import { useLatest, useReducedMotion } from '../basics-kit/kit';
import '../shared/ember.css';
import '../display-kit/display-kit.css';
import './breadcrumbs.css';
export interface Crumb {
label: string;
/** When set the crumb is a link; otherwise a button that calls `onNavigate`. */
href?: string;
}
export interface BreadcrumbsProps {
/** The path, root first. An array, or one string split on "/". The last item is the current page. */
items: Crumb[] | string;
/** The most crumbs shown before the middle collapses. The path also collapses to fit its width. */
maxVisible?: number;
/** A crumb (or a collapsed one, from the menu) was chosen. */
onNavigate?: (index: number, crumb: Crumb, event: MouseEvent | KeyboardEvent) => void;
accent?: Accent;
'aria-label'?: string;
className?: string;
}
const parse = (items: Crumb[] | string): Crumb[] =>
typeof items === 'string'
? items
.split('/')
.map((s) => s.trim())
.filter(Boolean)
.map((label) => ({ label }))
: items.filter((c) => c && c.label);
function Slash() {
return (
<svg className="em-crumbs__slash" viewBox="0 0 8 16" aria-hidden="true">
<path d="M6.25 2.5 1.75 13.5" />
</svg>
);
}
/** Which crumbs show: all, or the first, a "…" and the last `tail`. */
type Layout = { collapsed: false } | { collapsed: true; tail: number };
export function Breadcrumbs({
items: itemsProp,
maxVisible = 4,
onNavigate,
accent: accentProp,
'aria-label': ariaLabel = 'Breadcrumb',
className,
}: BreadcrumbsProps) {
const accent = resolveAccent(accentProp);
const uid = useId();
const reduced = useReducedMotion();
const items = useMemo(() => parse(itemsProp), [itemsProp]);
const sig = items.map((c) => c.label).join('\u0000');
const n = items.length;
const cap = Math.max(2, Math.floor(maxVisible));
const navRef = useRef<HTMLElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const moreRef = useRef<HTMLButtonElement>(null);
const [layout, setLayout] = useState<Layout>({ collapsed: false });
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const navigate = useLatest(onNavigate);
/* ---------------------------------------------------------------- fitting */
const fit = useCallback(() => {
const nav = navRef.current;
const m = measureRef.current;
if (!nav || !m) return;
const avail = nav.clientWidth;
const widths = Array.from(m.querySelectorAll<HTMLElement>('[data-w]')).map((el) => el.getBoundingClientRect().width);
const sep = m.querySelector<HTMLElement>('[data-sep]')?.getBoundingClientRect().width ?? 0;
const more = m.querySelector<HTMLElement>('[data-more]')?.getBoundingClientRect().width ?? 0;
const sum = (a: number, b: number) => {
let t = 0;
for (let i = a; i < b; i++) t += widths[i] ?? 0;
return t;
};
let next: Layout = { collapsed: false };
const whole = sum(0, n) + sep * Math.max(0, n - 1);
if (n > 2 && (n > cap || whole > avail + 0.5)) {
let tail = 1;
for (let k = Math.min(n - 2, cap - 2); k >= 1; k--) {
const w = widths[0] + sep + more + sep + sum(n - k, n) + sep * (k - 1);
if (w <= avail + 0.5) {
tail = k;
break;
}
}
next = { collapsed: true, tail };
}
setLayout((prev) =>
prev.collapsed === next.collapsed && (!prev.collapsed || !next.collapsed || prev.tail === next.tail) ? prev : next,
);
}, [n, cap]);
useLayoutEffect(() => {
fit();
const nav = navRef.current;
if (!nav) return;
const ro = new ResizeObserver(() => fit());
ro.observe(nav);
document.fonts?.ready.then(() => fit());
return () => ro.disconnect();
}, [fit, sig]);
const hidden = layout.collapsed ? items.slice(1, n - layout.tail).map((c, k) => ({ c, i: k + 1 })) : [];
const visible: ({ kind: 'crumb'; i: number } | { kind: 'more' })[] = layout.collapsed
? [{ kind: 'crumb', i: 0 }, { kind: 'more' }, ...items.slice(n - layout.tail).map((_, k) => ({ kind: 'crumb' as const, i: n - layout.tail + k }))]
: items.map((_, i) => ({ kind: 'crumb' as const, i }));
useEffect(() => {
if (!layout.collapsed) setOpen(false);
}, [layout.collapsed]);
/* ---------------------------------------------------------------- the "…" menu */
const pop = usePop(open, reduced);
const panelRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const hl = useRef<Highlight | null>(null);
if (!hl.current) hl.current = new Highlight();
const hlLoop = useMemo(() => createLoop((dt) => hl.current!.step(dt)), []);
useEffect(() => () => hlLoop.stop(), [hlLoop]);
const focusOnOpen = useRef<number>(-1);
const setPanel = useCallback(
(el: HTMLDivElement | null) => {
panelRef.current = el;
pop.panelRef(el);
},
[pop.panelRef],
);
useLayoutEffect(() => {
const el = panelRef.current;
const btn = moreRef.current;
if (!pop.mounted || !el || !btn) return;
const p = placeVertical(btn.getBoundingClientRect(), el.offsetWidth, el.offsetHeight, 6);
el.style.left = `${Math.round(p.x)}px`;
el.style.top = `${Math.round(p.y)}px`;
el.style.transformOrigin = `${p.ox.toFixed(1)}px ${p.oy.toFixed(1)}px`;
}, [pop.mounted, open]);
useLayoutEffect(() => {
const H = hl.current!;
const list = listRef.current;
if (!list) {
H.el = null;
return;
}
H.el = list.querySelector<HTMLElement>('.dk-hi');
const row = active >= 0 && open ? list.querySelector<HTMLElement>(`[data-k="${active}"]`) : null;
if (!row) return H.hide();
if (H.to(row.offsetTop, row.offsetHeight, reduced)) hlLoop.wake();
}, [active, open, pop.mounted, reduced, hlLoop]);
useEffect(() => {
if (!open) return;
const el = panelRef.current;
if (!el) return;
const k = focusOnOpen.current;
const row = k >= 0 ? el.querySelector<HTMLElement>(`[data-k="${k}"]`) : null;
(row ?? el).focus({ preventScroll: true });
}, [open, pop.mounted]);
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
const t = e.target as Node;
if (panelRef.current?.contains(t) || moreRef.current?.contains(t)) return;
setOpen(false);
};
const onScroll = () => setOpen(false);
document.addEventListener('pointerdown', onDown);
window.addEventListener('resize', onScroll);
return () => {
document.removeEventListener('pointerdown', onDown);
window.removeEventListener('resize', onScroll);
};
}, [open]);
const openMenu = (first: number) => {
focusOnOpen.current = first;
setActive(first);
setOpen(true);
};
const closeMenu = (focusBack: boolean) => {
setOpen(false);
setActive(-1);
if (focusBack) moreRef.current?.focus({ preventScroll: true });
};
const choose = (i: number, e: MouseEvent | KeyboardEvent) => {
const c = items[i];
if (!c) return;
navigate.current?.(i, c, e);
};
const onMoreKey = (e: KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openMenu(0);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
openMenu(hidden.length - 1);
}
};
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
const m = hidden.length;
let next: number | null = null;
if (e.key === 'ArrowDown') next = (active + 1 + m) % m;
else if (e.key === 'ArrowUp') next = (active - 1 + m) % m;
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = m - 1;
else if (e.key === 'Escape') {
e.preventDefault();
closeMenu(true);
return;
} else if (e.key === 'Tab') {
closeMenu(false);
return;
} else if ((e.key === 'Enter' || e.key === ' ') && active >= 0) {
e.preventDefault();
const target = hidden[active];
closeMenu(true);
if (target) choose(target.i, e);
return;
}
if (next === null) return;
e.preventDefault();
setActive(next);
panelRef.current?.querySelector<HTMLElement>(`[data-k="${next}"]`)?.focus({ preventScroll: true });
};
/* ---------------------------------------------------------------- render */
const renderCrumb = (i: number) => {
const c = items[i];
const current = i === n - 1;
const label = <span className="em-crumbs__label">{c.label}</span>;
if (current)
return (
<span className="em-crumbs__current" aria-current="page" title={c.label}>
{label}
</span>
);
const common = {
className: 'em-crumbs__link',
title: c.label,
};
return c.href ? (
<a {...common} href={c.href} onClick={(e) => choose(i, e)}>
{label}
</a>
) : (
<button {...common} type="button" onClick={(e) => choose(i, e)}>
{label}
</button>
);
};
return (
<nav ref={navRef} className={`ember em-crumbs${className ? ` ${className}` : ''}`} data-accent={accent} aria-label={ariaLabel}>
<ol className="em-crumbs__list">
{visible.map((v, k) => (
<Fragment key={v.kind === 'more' ? 'more' : `c${v.i}`}>
{k > 0 ? (
<li className="em-crumbs__sep" aria-hidden="true">
<Slash />
</li>
) : null}
{v.kind === 'more' ? (
<li className="em-crumbs__item">
<button
ref={moreRef}
type="button"
className="em-crumbs__more"
aria-label={`Show ${hidden.length} more`}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? `${uid}-menu` : undefined}
data-open={open || undefined}
onClick={() => (open ? closeMenu(false) : openMenu(-1))}
onKeyDown={onMoreKey}
>
<span aria-hidden="true">…</span>
</button>
</li>
) : (
<li className={`em-crumbs__item${v.i === n - 1 ? ' is-current' : ''}`}>{renderCrumb(v.i)}</li>
)}
</Fragment>
))}
</ol>
{/* Off-screen copies that measure every crumb at its rendered width. */}
<div ref={measureRef} className="em-crumbs__measure" aria-hidden="true">
{items.map((c, i) => (
<span key={i} data-w className={i === n - 1 ? 'em-crumbs__current' : 'em-crumbs__link'}>
<span className="em-crumbs__label">{c.label}</span>
</span>
))}
<span data-sep className="em-crumbs__sep">
<Slash />
</span>
<span data-more className="em-crumbs__more">
…
</span>
</div>
{pop.mounted ? (
<div
ref={setPanel}
id={`${uid}-menu`}
className="em-pop em-crumbs__panel"
role="menu"
aria-label="Collapsed path"
tabIndex={-1}
onKeyDown={onPanelKey}
onPointerLeave={() => setActive(-1)}
>
<div ref={listRef} className="em-crumbs__rows">
<div className="dk-hi" aria-hidden="true" />
{hidden.map(({ c, i }, k) => (
<div
key={i}
data-k={k}
role="menuitem"
tabIndex={-1}
className={`em-row em-crumbs__row${k === active ? ' is-active' : ''}`}
onPointerMove={() => k !== active && setActive(k)}
onClick={(e) => {
closeMenu(true);
choose(i, e);
}}
>
<span className="em-crumbs__depth em-mono" aria-hidden="true">
{String(i + 1).padStart(2, '0')}
</span>
<span className="em-crumbs__rowlabel">{c.label}</span>
</div>
))}
</div>
</div>
) : null}
</nav>
);
}
export default Breadcrumbs;
Customize
012345678904
A pointed-at crumb brightens to full text colour; a path too long for its width folds its middle into a quiet … that opens a menu of the hidden levels.
Install
npx shadcn@latest add https://obsydeui.com/r/breadcrumbs.jsonUsage
Import it from where the CLI puts it, or copy the files below by hand.
import { Breadcrumbs } from '@/components/obsyde/ember/breadcrumbs/Breadcrumbs';
export function Example() {
return (
<Breadcrumbs />
);
}
Files
Plain React, TypeScript and CSS. No dependencies. The download keeps these paths.
ember/breadcrumbs/Breadcrumbs.tsxcomponentember/breadcrumbs/breadcrumbs.cssstylesember/breadcrumbs/BreadcrumbsDemo.tsxdemoember/display-kit/place.tssharedember/display-kit/highlight.tssharedember/shared/spring.tssharedember/shared/accent.tssharedember/display-kit/display-kit.csssharedember/display-kit/motion.tssharedember/basics-kit/kit.tssharedember/shared/ember.cssshared
API
| Prop | Type | Default |
|---|---|---|
accentAccent | 'ember' | 'volt' | 'arc' | 'ember' |
pathPath | string | 'Ember / Navigation / Breadc…' |
maxVisibleMax visible | number | 4 |