9 Breadcrumb UI Patterns That Survive Deep Paths
Breadcrumb UI is the trail of links at the top of a page showing where you are, from Home to the item in view.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Three separator styles
- 02 Collapsed middle steps
- 03 Current page emphasis
- 04 Sibling path preview
- 05 Width-aware auto truncation
- 06 Icon plus label crumbs
- 07 Step-back slide out
- 08 Structured-data trail
- 09 Mobile back-to-parent
The order is not a ranking; it is the life of a trail on screen. The first three are about looks: swap the separator for a different mood (01), collapse the middle when the path gets long (02), and make the current page unmistakable (03). The next two are about movement: preview sibling categories one level up (04) and hide early steps behind an ellipsis as the width shrinks (05). The sixth compresses information with icons (06), and the seventh animates the hop between levels (07). The last two look past the desktop screen: a structured-data copy for search engines (08) and a mobile pattern that keeps only the parent (09). All nine sit on nav with ol and li markup, and every one has a real keyboard path, not just a mouse one.
01Three separator styles
The same path cycles through slash, chevron, and middle-dot separators. The slash and the middle dot come from li:not(:last-child)::after content and the chevron from an aria-hidden span, so a screen reader hears only the names. Click a pill to lock that separator, or move with the left and right arrow keys.
function pick(i) {
root.classList.remove('is-demo');
ind.style.setProperty('--i', i);
pills.forEach(function (p, n) { p.setAttribute('aria-pressed', n === i ? 'true' : 'false'); });
lists.forEach(function (l, n) {
var on = n === i;
l.classList.toggle('is-on', on);
l.setAttribute('aria-hidden', on ? 'false' : 'true');
[].forEach.call(l.querySelectorAll('a'), function (a) { a.tabIndex = on ? 0 : -1; });
});
}
02Collapsed middle steps
When the path runs more than a few levels deep, the middle steps fold into a single three-dot button. Pressing it drops the hidden steps down as a list that Escape or a click outside closes, and the arrow keys move inside the list. The button reports its state through aria-expanded.
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen()) { e.preventDefault(); setOpen(false, true); return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
var i = links.indexOf(document.activeElement);
if (i < 0 && document.activeElement !== btn) return;
e.preventDefault();
if (!isOpen()) { setOpen(true, false); return; }
var step = e.key === 'ArrowDown' ? 1 : links.length - 1;
links[(i + step + links.length) % links.length].focus({ preventScroll: true });
});
03Current page emphasis
Only the last crumb stays plain text, carrying heavier weight and an underline lifted off the glyphs with text-underline-offset. Its aria-current="page" tells screen readers this is where you are. Click an earlier crumb and it becomes the new current page, with every crumb after it hidden.
function makeCurrent(i) {
root.classList.remove('is-demo');
at = i;
lis.forEach(function (li, n) {
li.hidden = n > i;
var c = crumbs[n];
c.classList.toggle('ce__cur', n === i);
if (n === i) c.setAttribute('aria-current', 'page');
else c.removeAttribute('aria-current');
});
val.textContent = crumbs[i].textContent;
}
04Sibling path preview
Hovering a middle step, or pressing the down arrow on it, previews the other categories on that level as a list. You can jump sideways to a neighboring category without climbing up and walking back down, and the arrow keys keep moving inside the list before Escape closes it. The step carries aria-haspopup and aria-expanded.
function open(first) {
live();
item.classList.add('is-open');
btn.setAttribute('aria-expanded', 'true');
if (first) opts[0].focus({ preventScroll: true });
}
function shut(back) {
item.classList.remove('is-open');
btn.setAttribute('aria-expanded', 'false');
if (back) btn.focus({ preventScroll: true });
}
item.addEventListener('pointerenter', function () { open(false); });
item.addEventListener('pointerleave', function () { shut(false); });
05Width-aware auto truncation
As the box narrows, the steps right after Home disappear into an ellipsis so the trail never wraps to a second line. A ResizeObserver watches the panel, and the moment the list's scrollWidth outgrows the nav it marks those steps hidden, restoring them in order as the width returns. Drag the handle to drive the same logic by hand.
function fit() {
list.style.maxWidth = 'none'; // natural width while measuring
hideables.forEach(function (el) { el.hidden = false; });
dots.hidden = true;
for (var i = 0; i < hideables.length && list.scrollWidth > nav.clientWidth; i++) {
dots.hidden = false;
hideables[i].hidden = true;
}
list.style.maxWidth = '';
}
new ResizeObserver(fit).observe(panel);
06Icon plus label crumbs
The first crumb is a house icon alone, the middle ones pair a folder icon with a label, and the last pairs a tag icon with its label in the filled current-page style, so each level shows only as much as it needs. Icons take the text color straight from currentColor, whatever state the crumb is in. The button below folds the middle labels away, and clicking any crumb moves aria-current to it.
toggle.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = root.classList.toggle('is-compact');
toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
toggle.textContent = on ? 'Icon and label' : 'Icons only';
});
07Step-back slide out
Clicking an upper step slides every crumb behind it out to the left, and the go-back-down button pushes the same crumbs in from the right. The animationend event is what hides a crumb after it finishes leaving on a cubic-bezier(.2, .8, .2, 1) curve, so the document size never jumps.
list.addEventListener('animationend', function (e) {
var li = e.target.closest('.st__item');
if (!li) return;
if (li.classList.contains('is-out')) { li.hidden = true; li.classList.remove('is-out'); }
else if (li.classList.contains('is-in')) { li.classList.remove('is-in'); }
});
list.addEventListener('click', function (e) {
var a = e.target.closest('.st__link');
if (!a || !a.hasAttribute('data-i')) return;
e.preventDefault();
stop();
for (var k = items.length - 1; k > +a.getAttribute('data-i'); k--) {
if (!items[k].hidden) leave(items[k]);
}
});
08Structured-data trail
The visible trail is mirrored once more as BreadcrumbList JSON-LD. The document holds a real application/ld+json script tag, and the button reads the on-screen ol to rebuild itemListElement with its position, name, and item from scratch. The card above it previews the line that trail becomes on a results page.
function build() {
var out = [], n = 0;
for (var i = 0; i < all.length; i++) {
if (all[i].hidden) continue;
var a = all[i].querySelector('.jl__crumb');
n++;
out.push({ "@type": "ListItem", position: n, name: a.textContent.trim(),
item: "https://godrichstory.com/" + a.getAttribute('data-seg') });
}
ld.textContent = JSON.stringify({ "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: out });
}
09Mobile back-to-parent
Below a breakpoint, the whole trail folds into a back row that shows only the parent step. A matchMedia('(max-width: 420px)') query subscribes to the real screen width and swaps the rows by itself, and the full path unfolds again when the width returns. The back row announces its destination through its aria-label, and Enter fires it like any button.
var mq = window.matchMedia('(max-width: 420px)');
function apply(narrow) {
root.classList.remove('is-demo');
root.classList.toggle('is-narrow', narrow);
chips.wide.setAttribute('aria-pressed', String(!narrow));
chips.narrow.setAttribute('aria-pressed', String(narrow));
}
function reflect() { apply(manual === null ? mq.matches : manual); }
if (mq.addEventListener) { mq.addEventListener('change', function () { manual = null; reflect(); }); }
Where it breaks — the trap
The trap that took the longest was the measuring moment in 05. With max-width: 100% on the list, the last crumb wrapped instead of overflowing and scrollWidth came back smaller than reality, so the steps never folded no matter how narrow the panel got. Releasing maxWidth for the three lines that do the measuring, then restoring it, is what finally made the steps move. On the renderer's clock, at 209px the three steps after Home hid behind the ellipsis, and at 311px Laptops came back first. Hiding has its own trap too: hidden alone does not remove a flex child, so [hidden] { display: none } had to be written out.
The second trap was the height of lists you cannot see. The dropdowns in 02 and 04 are absolutely positioned, which takes them out of the parent's height calculation while scrollHeight still counts them, so a scrollbar appeared at 320px. Reserving the list height as padding-bottom on the root fixed both screens. The third was capturing a still image of something in motion. In 06 the opacity ramp of the rising crumbs was long enough that the poster caught two states ghosted on top of each other; squeezing the ramp into two frames lifted the measured intensity from 46 to 91.9. When a state changes, cutting in a single frame beats fading. All nine sources, vanilla and React with the same values, sit in the zip that opens with the password vfj8frcg.
Accessibility (reduced-motion)
All nine wrap a nav with aria-label, lay crumbs out as ol and li, and draw separators as pseudo-elements or aria-hidden glyphs that screen readers skip. aria-current="page" marks the current page in all nine, and 03, 06, and 08 move it as you click, while the folding lists in 02 and 04 report their open state through aria-expanded. The keyboard follows the mouse everywhere: 01 moves between the pills with the left and right arrows, 02 and 04 open with the down arrow and walk the list with up and down before Escape closes it, 03 and 06 take Enter on the crumbs themselves, 05 moves the handle with left and right, and 09 fires the back row with Enter. Every focus call carries the preventScroll option so an embedded demo never drags the page around. Under prefers-reduced-motion: reduce the preview loops and the transitions stop, while real interaction and the state values stay. The MDN aria-current page covers the attribute, and MDN ResizeObserver covers the width watching.
More wayfinding parts live in the navigation category, and JS-driven parts like these in the JS category.
FAQ
Do breadcrumbs really help search visibility?
Drawing the trail on screen isn't enough; pairing it with BreadcrumbList structured data, as in 08, is how a search engine understands the path. Once the page is recrawled, the results can show the category trail in place of the raw URL, but the structured data has to be there first. Building the array from the visible list, as this demo does, keeps the two copies from drifting apart.
Can the last crumb stay a link?
A link from the current page to itself creates a loop, so it's better left as plain text. Keep it unlinked, add aria-current="page", and both eyes and screen readers know exactly where they are. Pattern 03 does exactly that.
Is it fine to type the separator into the markup?
It works, but every slash and chevron gets read aloud as its own item, chopping the path into fragments. Drawing separators through li:not(:last-child)::after content, or an aria-hidden glyph, keeps the announcement clean, and pattern 01 shows that difference across three separator styles at once.