Breadcrumb
stimeo--breadcrumb
A breadcrumb that folds its middle levels behind an ellipsis when the row runs out of width.
A breadcrumb following the WAI-ARIA Breadcrumb pattern. The trail itself and the mark for the current page live in your markup; what this owns is folding it to fit. When the row no longer fits on one line, the middle levels you nominated are hidden behind an ellipsis button, and pressing it opens the full path. When the width comes back, everything is shown again and the ellipsis goes away. It watches both the width and any change to the items or their wording, so it keeps up when the trail is replaced later. If folding would hide the item you are focused on, focus moves to the ellipsis first, so the keyboard never loses its place. Separators and the look are yours.
Keyboard
| Key | Action |
|---|---|
| Enter / Space | Expand or re-collapse the full path on the ellipsis button. |
<%# Markup for the breadcrumb demo.
The APG structure (nav + ol + aria-current="page") lives in the markup; the
library only handles the responsive behavior of collapsing middle items into a
"…" disclosure button when width runs short. Separators are drawn by demo.css's
::after. aria-controls lists the id of *every* item the button collapses, so the
control relationship is complete rather than pointing at the first one only.
About the href values — this part is demo scaffolding, not a pattern to copy. A real
breadcrumb links to real paths and wants Turbo to handle them; replace the hrefs and
drop the data-turbo attribute when you copy this.
A catalog demo has nowhere real to go, and the usual placeholder `href="#"` is worse
than useless here: Turbo reads it as a visit to the current page, re-renders the body
from the server's HTML, and the trail you just expanded snaps shut (then flashes open
from the cached snapshot on the way back). Pointing at a same-page fragment only
helps on the first press — once the URL carries that hash, the next press is a
same-URL visit again and resets just the same, which is measurable in the browser.
So the demo opts out of Turbo for this subtree (data-turbo is inherited, so one
attribute covers every link): the browser then treats them as what they are, a scroll
to an element in this document, and the controller's state is never torn down. %>
<div id="breadcrumb-demo" class="breadcrumb-demo" data-turbo="false">
<%# The trail collapses only when it does not fit, so the demo needs a width it can
lose. Widen it past the full trail and nothing collapses and nothing scrolls;
narrow it and the middle items bank behind the "…". %>
<label class="demo-width-control">
<span><%= t("components.breadcrumb.demo.width") %></span>
<input type="range" min="220" max="640" value="352" data-breadcrumb-demo-width>
</label>
<nav
class="breadcrumb"
data-controller="stimeo--breadcrumb"
aria-label="<%= t("components.breadcrumb.demo.label") %>">
<ol class="breadcrumb__list" data-stimeo--breadcrumb-target="list">
<li class="breadcrumb__item">
<a href="#breadcrumb-demo"><%= t("components.breadcrumb.demo.home") %></a>
</li>
<li class="breadcrumb__item" data-stimeo--breadcrumb-target="ellipsis" hidden>
<button
type="button"
class="breadcrumb__ellipsis"
aria-expanded="false"
aria-controls="breadcrumb-collapsed-1 breadcrumb-collapsed-2 breadcrumb-collapsed-3"
aria-label="<%= t("components.breadcrumb.demo.expand") %>"
data-stimeo--breadcrumb-target="trigger"
data-action="click->stimeo--breadcrumb#toggle">…</button>
</li>
<li
class="breadcrumb__item"
id="breadcrumb-collapsed-1"
data-stimeo--breadcrumb-target="collapsible">
<a href="#breadcrumb-demo"><%= t("components.breadcrumb.demo.section") %></a>
</li>
<li
class="breadcrumb__item"
id="breadcrumb-collapsed-2"
data-stimeo--breadcrumb-target="collapsible">
<a href="#breadcrumb-demo"><%= t("components.breadcrumb.demo.subsection") %></a>
</li>
<li
class="breadcrumb__item"
id="breadcrumb-collapsed-3"
data-stimeo--breadcrumb-target="collapsible">
<a href="#breadcrumb-demo"><%= t("components.breadcrumb.demo.category") %></a>
</li>
<li class="breadcrumb__item">
<a href="#breadcrumb-demo" aria-current="page">
<%= t("components.breadcrumb.demo.current") %>
</a>
</li>
</ol>
</nav>
</div>
/*
* Presentation-only styles for the breadcrumb demo.
* Collapse/expand is the library toggling the hidden attribute of collapsible items
* and the ellipsis, plus the trigger's aria-expanded. Here we narrow the container
* to force overflow and draw the "/" separator with ::after.
*/
.breadcrumb-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* The width the slider drives. The declared value is the starting point and the
fallback when the sidecar script is absent: narrow enough that the trail collapses,
so the component's own behavior is what greets the reader.
The frame is what makes the width legible — without it the container has no visible
edge and the trail appears to shorten for no reason. The controller reads both widths
off the list itself, so this padding does not skew its overflow check. */
.breadcrumb {
max-width: 22rem;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
overflow: hidden;
}
/* When the user expands the collapsed trail, let the row scroll instead of clipping it,
so every item — including the current page — can be reached inside the container. It
scrolls rather than spilling out: `overflow: visible` let the trail run past the edge
of a narrow card (measured at ~529px out of a 200px card), which is not something a
demo should be teaching. Only the inline axis is relaxed: the list stays nowrap so its
measured width is unchanged, which avoids retriggering the controller's
ResizeObserver-driven overflow check (wrapping it would make the trail re-collapse).
The "…" button doubles as the re-collapse toggle, so it stays in place.
Keyboard reach comes from the links inside the row, which are all in the tab order —
a scroll container reachable only by pointer would fail WCAG 2.1.1. `scroll-padding`
keeps the item a link scrolls into view clear of the sticky-feeling left edge. */
.breadcrumb:has([data-stimeo--breadcrumb-target="trigger"][aria-expanded="true"]) {
overflow-x: auto;
scroll-padding-inline: 0.25rem;
}
.breadcrumb__list {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
white-space: nowrap;
}
.breadcrumb__item {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
}
/* Collapsing is expressed purely through the hidden attribute, and the author-origin
`display: inline-flex` above always beats the UA's `[hidden] { display: none }`
(origin, not specificity — reordering or lowering specificity does not help). Restore
it here so this file works on its own wherever it is pasted. */
.breadcrumb [hidden] {
display: none !important;
}
.breadcrumb__item:not(:last-child)::after {
content: "/";
color: var(--color-text-subtle);
}
/* The theme-aware accent *text* token, not the raw brand accent: the latter measures
2.86:1 on the light canvas (#0ea88f on #f8fafc), below the 4.5:1 floor for body-size
text. Same substitution the rest of the catalog already carries. */
.breadcrumb__item a {
color: var(--accent-700);
text-decoration: none;
}
.breadcrumb__item a[aria-current="page"] {
color: var(--fg, var(--color-text));
font-weight: 600;
pointer-events: none;
}
.breadcrumb__ellipsis {
padding: 0 0.25rem;
border: 0;
background: none;
color: var(--color-text-muted);
font: inherit;
line-height: 1;
cursor: pointer;
}
.breadcrumb__ellipsis:hover {
color: var(--fg, var(--color-text));
}
.breadcrumb__ellipsis:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
// Breadcrumb demo (consumer-side JS).
//
// The controller needs no knob of its own — it watches the trail with a ResizeObserver
// and collapses the middle items when they stop fitting. This slider only changes the
// width they have to fit into, so the threshold can be crossed in both directions:
// wide enough and the whole trail shows with no scrolling, narrow and it banks behind
// the "…".
document.querySelectorAll(".breadcrumb-demo").forEach((root) => {
const trail = root.querySelector('[data-controller~="stimeo--breadcrumb"]');
const range = root.querySelector("[data-breadcrumb-demo-width]");
if (!trail || !range) return;
const apply = () => {
trail.style.maxWidth = `${range.value}px`;
};
apply();
range.addEventListener("input", apply);
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--breadcrumb"
Targets
| Name | Description | Attribute |
|---|---|---|
list
required
|
The <ol> trail container; its width is measured to detect overflow. |
data-stimeo--breadcrumb-target="list" |
collapsible
|
An author-marked middle item that is hidden when the trail collapses. | data-stimeo--breadcrumb-target="collapsible" |
ellipsis
|
The ellipsis (…) item shown in place of the collapsed items while overflowing. | data-stimeo--breadcrumb-target="ellipsis" |
trigger
|
The disclosure button that expands/re-collapses the trail; its aria-expanded is synced. |
data-stimeo--breadcrumb-target="trigger" |
Actions
| Name | Description | Action |
|---|---|---|
toggle
|
Expands or re-collapses the trail and dispatches the toggle event. No-op while the trail fits. | stimeo--breadcrumb#toggle |
update
|
Re-measures the trail and re-renders — call it after a change the resize/mutation observers cannot see, such as a web font swap. | stimeo--breadcrumb#update |
Events
| Name | Description | Event |
|---|---|---|
toggle
|
Dispatched when the user expands or re-collapses the trail; detail carries { expanded }. |
stimeo--breadcrumb:toggle |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
hidden |
Collapsible items | Present while collapsed, removed while expanded or when the trail fits. |
hidden |
Ellipsis | Removed while overflowing, present when the trail fits or nothing is marked collapsible. |
aria-expanded |
Trigger | "true" while the collapsed items are expanded; always "false" while the trail fits. |