GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

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 put their breadcrumb css on nav with ol and li markup, and every one has a real keyboard path, not just a mouse one. Splitting the screen and then losing the way inside it pairs with Resizable Split Pane Layouts, and sending the path outward is inherited by Social Share Buttons.

01Three separator styles

What these breadcrumb ui examples show

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.

Three separator styles
Open in a new tab

01 · Three separator styles — Measured area 5.202% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

::aftercontentsteps(1, end)

Where to look in the code

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

What happens on screen

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.

Collapsed middle steps
Open in a new tab

02 · Collapsed middle steps — Measured area 7.289% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-expandedtranslateYEscape

How to read the code

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

What moves

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.

Current page emphasis
Open in a new tab

03 · Current page emphasis — Measured area 5.282% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-currentfont-weighttext-underline-offset

What to change

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

What you see

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.

Sibling path preview
Open in a new tab

04 · Sibling path preview — Measured area 10.567% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-haspopupArrowDownopacity

Where to look in the code

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

What happens on screen

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.

Width-aware auto truncation
Open in a new tab

05 · Width-aware auto truncation — Measured area 9.197% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

ResizeObserverscrollWidthhidden

How to read the code

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

What moves

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.

Icon plus label crumbs
Open in a new tab

06 · Icon plus label crumbs — Measured area 6.76% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

currentColorinline-flexaria-label

What to change

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

What you see

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.

Step-back slide out
Open in a new tab

07 · Step-back slide out — Measured area 2.395% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXcubic-bezieranimationend

Where to look in the code

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

What happens on screen

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.

Structured-data trail
Open in a new tab

08 · Structured-data trail — Measured area 19.506% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

BreadcrumbListapplication/ld+jsonitemListElement

How to read the code

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

What moves

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.

Mobile back-to-parent
Open in a new tab

09 · Mobile back-to-parent — Measured area 7.014% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

matchMediatranslateXaria-label

What to change

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. The coordinate math for cutting away the itchy part is reused with the same handle parts in Image Crop JavaScript. 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

Does a breadcrumb navigation ui 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. Simplifying a path to finger size for a counter screen is what Kiosk UI Design trims well.

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.

Enter the archive password

The password is inside this article. You will find it as you read.