Skip to content

role navigation

Breadcrumb draft v0.1.0

Shows where the current page sits in a hierarchy, with a link to each level above it.

A breadcrumb lists the pages above the current one, from the top of the hierarchy down, each as a link, and ends with the current page, which is not a link. It tells people where they are and lets them go up one or several levels in one activation. It is a navigation landmark of its own, separate from the main navigation.

Web implementation

Root .ds-breadcrumb on a <nav>. Properties map to attributes: size → data-sizelabel → aria-label

packages/components/src/components/breadcrumb/breadcrumb.css
/*
 * Breadcrumb — vanilla CSS reference implementation.
 * Spec:   packages/components/specs/components/breadcrumb.spec.json
 * Tokens: packages/tokens/dist/css/components/breadcrumb.css (generated from packages/tokens/src/components/breadcrumb.tokens.json)
 *
 * Rules:
 *  - Only --breadcrumb-* custom properties may appear here. No raw values, no semantic tokens.
 *  - The root is a nav landmark; items are an ordered list of links, separated by a chevron; the
 *    last item is the current page.
 *  - Size is a data attribute on the root; link states are native pseudo-classes, forced with
 *    [data-state] on the root (every item) or on one item.
 */

.ds-breadcrumb {
  --_font-family: var(--breadcrumb-link-typography-medium-font-family);
  --_font-size: var(--breadcrumb-link-typography-medium-font-size);
  --_font-weight: var(--breadcrumb-link-typography-medium-font-weight);
  --_line-height: var(--breadcrumb-link-typography-medium-line-height);
  --_letter-spacing: var(--breadcrumb-link-typography-medium-letter-spacing);
  --_separator-size: var(--breadcrumb-separator-size-medium);

  box-sizing: border-box;
  min-inline-size: 0;
}

.ds-breadcrumb[data-size="small"] {
  --_font-family: var(--breadcrumb-link-typography-small-font-family);
  --_font-size: var(--breadcrumb-link-typography-small-font-size);
  --_font-weight: var(--breadcrumb-link-typography-small-font-weight);
  --_line-height: var(--breadcrumb-link-typography-small-line-height);
  --_letter-spacing: var(--breadcrumb-link-typography-small-letter-spacing);
  --_separator-size: var(--breadcrumb-separator-size-small);
}

.ds-breadcrumb__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--breadcrumb-container-gap);
  margin: 0;
  padding: 0;
  list-style: none;
}

.ds-breadcrumb__item {
  --_color: var(--breadcrumb-link-color-default);

  display: inline-flex;
  align-items: center;
  gap: var(--breadcrumb-container-gap);
}

.ds-breadcrumb__link {
  display: inline-flex;
  align-items: center;
  min-height: var(--breadcrumb-link-min-height);
  border-radius: var(--breadcrumb-link-border-radius);
  color: var(--_color);
  font-family: var(--_font-family);
  font-size: var(--_font-size);
  font-weight: var(--_font-weight);
  line-height: var(--_line-height);
  letter-spacing: var(--_letter-spacing);
  text-decoration: underline;
  text-underline-offset: calc(var(--_font-size) * 0.15);
  transition: color var(--breadcrumb-link-transition-duration) var(--breadcrumb-link-transition-easing);
}

.ds-breadcrumb__separator {
  display: inline-block;
  flex: none;
  inline-size: var(--_separator-size);
  block-size: var(--_separator-size);
  overflow: hidden;
  color: var(--breadcrumb-separator-color);
  font-family: var(--breadcrumb-separator-font-family);
  font-size: var(--_separator-size);
  font-weight: var(--breadcrumb-separator-font-weight);
  font-style: normal;
  font-variation-settings:
    "FILL" var(--breadcrumb-separator-fill),
    "GRAD" var(--breadcrumb-separator-grade),
    "opsz" var(--breadcrumb-separator-optical-size);
  font-feature-settings: "liga";
  line-height: var(--_separator-size);
  letter-spacing: normal;
  white-space: nowrap;
  direction: ltr;
  -webkit-font-smoothing: antialiased;
}

/* ---- States -------------------------------------------------------------- */

:where(.ds-breadcrumb[data-state="hover"], .ds-breadcrumb__item[data-state="hover"]) .ds-breadcrumb__link,
.ds-breadcrumb__link:hover {
  --_color: var(--breadcrumb-link-color-hover);
}

/* The current page: no link, no underline, stronger color. */
:where(.ds-breadcrumb[data-state="current"], .ds-breadcrumb__item[data-state="current"]) .ds-breadcrumb__link,
.ds-breadcrumb__link[aria-current] {
  --_color: var(--breadcrumb-link-color-current);
  text-decoration: none;
}

.ds-breadcrumb__link:focus {
  outline: none;
}

:where(.ds-breadcrumb[data-state="focus-visible"], .ds-breadcrumb__item[data-state="focus-visible"]) .ds-breadcrumb__link,
.ds-breadcrumb__link:focus-visible {
  outline: var(--breadcrumb-link-outline-width-focus-visible) solid var(--breadcrumb-link-outline-color-focus-visible);
  outline-offset: var(--breadcrumb-link-outline-offset-focus-visible);
}

@media (prefers-reduced-motion: reduce) {
  .ds-breadcrumb__link {
    transition: none;
  }
}
packages/components/src/components/breadcrumb/breadcrumb.template.mjs
/**
 * Breadcrumb — HTML template for the web implementation.
 * Pure function of the spec properties (packages/components/specs/components/breadcrumb.spec.json) -> HTML string.
 * A nav landmark labelled "Breadcrumb" holding an ordered list of links; the last item is the
 * current page, marked with aria-current="page" and not linked. The separators are decorative.
 *
 * Extra sandbox-only props: `state` on the root forces a state on every item; an entry's `state`
 * forces it on that item.
 */
import { escapeHtml, escapeAttr, renderIcon } from '../../lib/template-utils.mjs';

export const meta = { id: 'breadcrumb', selector: '.ds-breadcrumb' };

export function render(props = {}) {
  const { items = [{ label: 'Home', href: '#' }, { label: 'Current page' }], label = 'Breadcrumb', size = 'medium', state = null } = props;
  const rootAttrs = ['class="ds-breadcrumb"', `aria-label="${escapeAttr(label)}"`, `data-size="${escapeAttr(size)}"`];
  if (state && state !== 'default') rootAttrs.push(`data-state="${escapeAttr(state)}"`);
  const last = items.length - 1;
  const list = items.map((entry, i) => {
    const { label: text = `Page ${i + 1}`, href = null, state: itemState = null } = entry;
    const current = i === last;
    const itemAttrs = ['class="ds-breadcrumb__item"', 'data-part="item"'];
    if (itemState && itemState !== 'default') itemAttrs.push(`data-state="${escapeAttr(itemState)}"`);
    const link = current
      ? `<span class="ds-breadcrumb__link" data-part="link" aria-current="page">${escapeHtml(text)}</span>`
      : `<a class="ds-breadcrumb__link" data-part="link" href="${escapeAttr(href ?? '#')}">${escapeHtml(text)}</a>`;
    const separator = current ? '' : `<span class="ds-breadcrumb__separator" data-part="separator" aria-hidden="true">${renderIcon('chevron_right')}</span>`;
    return `<li ${itemAttrs.join(' ')}>${link}${separator}</li>`;
  });
  return `<nav ${rootAttrs.join(' ')}><ol class="ds-breadcrumb__list" data-part="list">${list.join('')}</ol></nav>`;
}

No other platform implementation recorded yet. Platform token outputs live under Platforms.