# Breadcrumb

> A nav with an ordered list of links and aria-current="page" on the last one.

Page: https://svelte-lean.vercel.app/primitives/breadcrumb · Markdown: https://svelte-lean.vercel.app/primitives/breadcrumb.md · Index: https://svelte-lean.vercel.app/llms.txt

An ordered list of links from the site root to the current page. Use it to show where a page sits in a hierarchy.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/breadcrumb.css';
</script>

<nav aria-label="Breadcrumb" data-slean="breadcrumb">
	<ol>
		<li><a href="/">Home</a></li>
		<li><a href="/primitives">Primitives</a></li>
		<li><a href="/primitives/breadcrumb" aria-current="page">Breadcrumb</a></li>
	</ol>
</nav>
```

The separators are drawn in CSS and never announced; turn on RTL to see them mirror.

## Examples

### Collapsed levels

```svelte
<script lang="ts">
	import '@svelte-lean/styles/breadcrumb.css';
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/popover.css';
</script>

<nav aria-label="Architecture breadcrumb" data-slean="breadcrumb">
	<ol>
		<li><a href="/">Home</a></li>
		<li>
			<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-size="small" popovertarget="trail-more" aria-label="Show 2 more levels">
				…
			</button>
			<div id="trail-more" popover data-slean="popover" data-slean-align="start">
				<ul class="levels">
					<li><a href="/docs">Docs</a></li>
					<li><a href="/docs/architecture/native-first">Architecture</a></li>
				</ul>
			</div>
		</li>
		<li><a href="/docs/architecture/runtime-tiers" aria-current="page">Runtime tiers</a></li>
	</ol>
</nav>

<style>
	.levels {
		display: grid;
		gap: 0.25rem;
		margin: 0;
		padding: 0;
		list-style: none;
	}
</style>
```

A long trail keeps its ends; the middle levels open from a button.

### Home icon

```svelte
<script lang="ts">
	import '@svelte-lean/styles/breadcrumb.css';
</script>

<nav aria-label="Docs breadcrumb" data-slean="breadcrumb">
	<ol>
		<li>
			<a href="/" aria-label="Home">
				<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
					<path d="M3 10.5 12 3l9 7.5V21h-6v-6H9v6H3z" />
				</svg>
			</a>
		</li>
		<li><a href="/docs">Docs</a></li>
		<li><a href="/docs/installation" aria-current="page">Installation</a></li>
	</ol>
</nav>
```

The SVG is hidden; the link keeps its name.

### Current page as text

```svelte
<script lang="ts">
	import '@svelte-lean/styles/breadcrumb.css';
</script>

<nav aria-label="Table breadcrumb" data-slean="breadcrumb">
	<ol>
		<li><a href="/">Home</a></li>
		<li><a href="/table">Table</a></li>
		<li><span aria-current="page">Pagination</span></li>
	</ol>
</nav>
```

When the current page should not be a link, a span is styled the same way.

### Trail from the route

```svelte
<script lang="ts">
	import '@svelte-lean/styles/breadcrumb.css';
	import { page } from '$app/state';

	// One level per path segment, the last one current.
	const trail = $derived.by(() => {
		const segments = page.url.pathname.split('/').filter(Boolean);
		return [
			{ href: '/', label: 'Home' },
			...segments.map((segment, i) => ({
				href: '/' + segments.slice(0, i + 1).join('/'),
				label: segment.charAt(0).toUpperCase() + segment.slice(1).replaceAll('-', ' ')
			}))
		];
	});
</script>

<nav aria-label="Page breadcrumb" data-slean="breadcrumb">
	<ol>
		{#each trail as level, i (level.href)}
			<li>
				<a href={level.href} aria-current={i === trail.length - 1 ? 'page' : undefined}>
					{level.label}
				</a>
			</li>
		{/each}
	</ol>
</nav>
```

One level per path segment; this is the trail of the page you are reading.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/breadcrumb.css';
```

Name the landmark with `aria-label="Breadcrumb"` (or `aria-labelledby`), list the levels in an `<ol>` directly inside it, and mark the last item with `aria-current="page"`. A page with several breadcrumbs gives each its own name.

Do not type separators into the markup; the stylesheet draws them.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<nav aria-label="Breadcrumb" data-slean="breadcrumb">` | – | yes | A named navigation landmark. |
| list | `<ol> directly in the root` | – | yes | One \<li\> per level, from the site root to the current page. |
| current page | `<a href aria-current="page"> or <span aria-current="page">` | – | yes | The last item. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab`/`Shift+Tab` | anywhere | Moves between the links (native) |
| `Enter` | focus on a link | Follows it (native) |

## Styling

`breadcrumb.css` lays the items out in a wrapping row, draws each separator as a chevron on `li + li::before` (mirrored under `:dir(rtl)`), mutes the ancestors and sets the current page in the text color. Links get the shared focus ring and, on a coarse pointer, the small control height. The rules use child combinators, so a list nested in an item keeps its own styles. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-leading` | `1.5` | line-height |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | gap |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap |
| `--slean-icon-chevron-right` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4l4 4-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-focus-ring` | `var(--slean-focus-ring-width) solid var(--slean-focus-ring-color)` | outline |
| `--slean-focus-ring-offset` | `2px` | outline-offset |
| `--slean-control-height-sm` | `2rem` | min-block-size |

State selectors the stylesheet targets, all from the platform or ARIA: `:dir(rtl)`, `:focus-visible`, `:hover`, `[aria-current="page"]`, `[href]`.

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<nav>`, `<ol>`, `aria-current="page"`, `:dir(rtl)`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<nav> + <ol> + aria-current="page"`

Tier 0: no behavior module. The Vite plugin maps `breadcrumb` to no module, and the page ships no Svelte Lean JavaScript for it.

Why this implementation

A breadcrumb is links in a list inside a navigation landmark, which the WAI-ARIA Authoring Practices spell out: a named `<nav>`, an `<ol>`, and `aria-current="page"` on the last item. Nothing in it needs a script.

The one part that goes wrong in markup is the separator: a typed “/” or “\>” is read aloud between every level. `breadcrumb.css` draws the separators as generated boxes with no text, mirrored under RTL, so the markup holds only the links.

### The browser owns

- the navigation landmark and its name
- the list and the number of levels
- the links, their focus and Enter
- the current page state (aria-current)

### Svelte Lean owns

- breadcrumb.css: the row, the separators drawn with borders and mirrored under RTL
- the current page’s weight, the link hover and focus ring, the coarse-pointer height
- the contract
- documentation

Accessibility

- A named `<nav>` landmark, told apart from the page’s other navigation by its name.
- An `<ol>`: screen readers announce the number of levels.
- `aria-current="page"` on the current page, a link or a span.
- The separators are empty generated boxes: not in the text, not announced.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<nav>, <ol>, aria-current` | Widely available | Not applicable |
| `rotate` | Baseline 2022 | A separator is an unturned corner |
| `:dir()` | Baseline 2023 | The chevrons point to the right under RTL as well |

Without JavaScript

Fully functional: the levels are plain links.

Server rendering and hydration

Static markup, rendered with the page from its route.

Nothing is attached; the links work before and after hydration alike.

Compatibility

The elements and `aria-current` are widely available. The chevrons use the `rotate` property (Baseline 2022) and `:dir()` (Baseline 2023); without the first a separator is an unturned corner, without the second the chevrons point right under RTL as well.

Tests and source

- `apps/playground/tests/primitives/feedback-navigation.spec.ts` Playwright: roles, names and states in the accessibility tree, keyboard, RTL, reduced motion, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/breadcrumb/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/breadcrumb/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/breadcrumb.css` the optional stylesheet
