# Nav

> A sidebar of links in <details name> groups, link tabs or an icon rail; aria-current="page" marks the current page and its group.

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

The navigation of an application or a site: a sidebar of links in groups, a strip of link tabs, or an icon rail. Links in lists, groups in native details elements, and aria-current on the current page.

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

	const ICONS = {
		home: 'M3 10.5 12 3l9 7.5V21h-6v-6H9v6H3z',
		compass: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18M15.5 8.5l-2 5-5 2 2-5z',
		form: 'M4 5h16v4H4zM4 13h10v4H4z',
		book: 'M4 4h6a2 2 0 0 1 2 2v14a2 2 0 0 0-2-2H4zM20 4h-6a2 2 0 0 0-2 2v14a2 2 0 0 1 2-2h6z'
	};
</script>

{#snippet icon(d: string)}
	<span data-slean-part="icon">
		<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path {d} /></svg>
	</span>
{/snippet}

<nav data-slean="nav" aria-label="Example sidebar">
	<h3>Primitives</h3>
	<ul>
		<li><a href="/primitives">{@render icon(ICONS.home)} Overview</a></li>
		<li>
			<details name="example-nav" open>
				<summary>{@render icon(ICONS.compass)} Navigation</summary>
				<ul>
					<li>
						<a href="/primitives/nav" aria-current="page">
							Nav <span data-slean="badge" data-color="primary">New</span>
						</a>
					</li>
					<li><a href="/primitives/tabs">Tabs</a></li>
					<li><a href="/primitives/breadcrumb">Breadcrumb</a></li>
				</ul>
			</details>
		</li>
		<li>
			<details name="example-nav">
				<summary>{@render icon(ICONS.form)} Forms</summary>
				<ul>
					<li><a href="/primitives/input">Input</a></li>
					<li><a href="/primitives/select">Select</a></li>
					<li><a href="/primitives/radio-group">Radio group</a></li>
				</ul>
			</details>
		</li>
	</ul>
	<h3>Guides</h3>
	<ul>
		<li><a href="/docs/installation">{@render icon(ICONS.book)} Installation</a></li>
	</ul>
</nav>
```

Opening Forms closes Navigation (the groups share a name); Navigation, closed, then carries the current page's fill. Works with page JavaScript disabled.

## Examples

### Link tabs

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

<nav data-slean="nav" data-slean-orientation="horizontal" aria-label="Navigation primitives">
	<ul>
		<li><a href="/primitives/nav" aria-current="page">Nav</a></li>
		<li><a href="/primitives/tabs">Tabs</a></li>
		<li><a href="/primitives/breadcrumb">Breadcrumb</a></li>
		<li><a href="/primitives/pagination">Pagination</a></li>
		<li><a href="/primitives/steps">Steps</a></li>
		<li><a href="/primitives/tree">Tree</a></li>
	</ul>
</nav>
```

Each link goes to its own page, so these are links with aria-current, not ARIA tabs. A strip wider than its container scrolls sideways; Tab scrolls the focused link into view.

### Icon rail

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/hint.css';
	import '@svelte-lean/styles/hover-card.css';
	import '@svelte-lean/styles/nav.css';

	const ICONS = {
		home: 'M3 10.5 12 3l9 7.5V21h-6v-6H9v6H3z',
		compass: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18M15.5 8.5l-2 5-5 2 2-5z',
		form: 'M4 5h16v4H4zM4 13h10v4H4z',
		book: 'M4 4h6a2 2 0 0 1 2 2v14a2 2 0 0 0-2-2H4zM20 4h-6a2 2 0 0 0-2 2v14a2 2 0 0 1 2-2h6z'
	};

	const navigation = [
		{ href: '/primitives', label: 'Overview', icon: ICONS.home },
		{
			id: 'navigation',
			label: 'Navigation',
			icon: ICONS.compass,
			links: [
				{ href: '/primitives/nav', label: 'Nav' },
				{ href: '/primitives/tabs', label: 'Tabs' }
			]
		},
		{ href: '/docs/installation', label: 'Installation', icon: ICONS.book }
	];
	const current = '/primitives/nav';

	let collapsed = $state(true);
</script>

{#snippet icon(d: string)}
	<span data-slean-part="icon">
		<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path {d} /></svg>
	</span>
{/snippet}

<button type="button" data-slean="button" data-variant="outlined" data-size="small" aria-pressed={collapsed} onclick={() => (collapsed = !collapsed)}>
	Collapse the sidebar
</button>

<nav data-slean="nav" data-slean-collapsed={collapsed || undefined} aria-label="Example rail">
	<ul>
		{#each navigation as item (item.label)}
			<li>
				{#if item.links && collapsed}
					<!-- In the rail a group is a button and a hover card of its links. -->
					<button type="button" interestfor="primitives-nav-rail-{item.id}" command="show-popover" commandfor="primitives-nav-rail-{item.id}">
						{@render icon(item.icon)}
						{item.label}
					</button>
					<div id="primitives-nav-rail-{item.id}" popover="hint" data-slean="hover-card" data-slean-side="right">
						<ul>
							{#each item.links as link (link.href)}
								<li><a href={link.href} aria-current={link.href === current ? 'page' : undefined}>{link.label}</a></li>
							{/each}
						</ul>
					</div>
				{:else if item.links}
					<details name="rail-nav" open={item.links.some((link) => link.href === current)}>
						<summary>{@render icon(item.icon)} {item.label}</summary>
						<ul>
							{#each item.links as link (link.href)}
								<li><a href={link.href} aria-current={link.href === current ? 'page' : undefined}>{link.label}</a></li>
							{/each}
						</ul>
					</details>
				{:else}
					<a
						href={item.href}
						aria-current={item.href === current ? 'page' : undefined}
						data-slean-hint={collapsed ? item.label : undefined}
						data-slean-hint-side="right"
					>
						{@render icon(item.icon)}
						{item.label}
					</a>
				{/if}
			</li>
		{/each}
	</ul>
</nav>
```

data-slean-collapsed clips each label after its icon; the label stays the accessible name. A link's hint and a group's hover card name them on screen: rest the pointer, Tab to a row, or click the group.

### From the route, with actions

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

	const groups = [
		{
			label: 'Navigation',
			links: [
				{ href: '/primitives/nav', label: 'Nav' },
				{ href: '/primitives/tabs', label: 'Tabs' },
				{ href: '/primitives/steps', label: 'Steps' }
			]
		},
		{
			label: 'Overlays',
			links: [
				{ href: '/primitives/dialog', label: 'Dialog' },
				{ href: '/primitives/popover', label: 'Popover' }
			]
		}
	];

	// Pinning is the application's: a set of hrefs, here for this visit.
	let pinned = $state(new Set<string>());
	function pin(href: string) {
		const next = new Set(pinned);
		if (!next.delete(href)) next.add(href);
		pinned = next;
	}
</script>

<nav data-slean="nav" aria-label="Primitives by category">
	<ul>
		{#each groups as group (group.label)}
			<!-- The group holding the current page is open on the server's first paint. -->
			<li>
				<details name="route-nav" open={group.links.some((link) => link.href === page.url.pathname)}>
					<summary>{group.label}</summary>
					<ul>
						{#each group.links as link (link.href)}
							<li>
								<a href={link.href} aria-current={link.href === page.url.pathname ? 'page' : undefined}>
									{link.label}
								</a>
								<button
									type="button"
									data-slean-part="actions"
									data-slean="button"
									data-variant="text"
									data-color="neutral"
									data-size="small"
									data-icon-only
									aria-label="Pin {link.label}"
									aria-pressed={pinned.has(link.href)}
									onclick={() => pin(link.href)}
								>
									<svg aria-hidden="true" viewBox="0 0 24 24" fill={pinned.has(link.href) ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M9 4h6l-1 6 4 4H6l4-4zM12 14v6" /></svg>
								</button>
							</li>
						{/each}
					</ul>
				</details>
			</li>
		{/each}
	</ul>
</nav>
```

The server renders the current link and opens its group, so the first paint is complete. The pin buttons are actions beside each link; what pinning does is the application's.

### In-page contents

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

	const sections = ['Markup', 'Groups', 'Rail', 'Styling'];
</script>

<div class="page">
	<nav data-slean="nav" data-slean-scrollspy aria-label="Example contents">
		<ul>
			{#each sections as section (section)}
				<li><a href="#primitives-nav-spy-{section.toLowerCase()}">{section}</a></li>
			{/each}
		</ul>
	</nav>
	<div class="content" tabindex="0" role="region" aria-label="Example article">
		{#each sections as section (section)}
			<section id="primitives-nav-spy-{section.toLowerCase()}">
				<p><strong>{section}</strong></p>
				<p>Scroll this region: the link of the section in view is marked.</p>
			</section>
		{/each}
	</div>
</div>

<style>
	.page { display: flex; gap: 1rem; }
	.page nav { inline-size: 9rem; flex: none; }
	.content { block-size: 12rem; overflow-y: auto; flex: 1; }
	.content section { min-block-size: 9rem; }
</style>
```

data-slean-scrollspy makes the nav a scroll target group: in Chromium the link of the section in view is marked as it scrolls, with no script. Elsewhere no link is marked and the links still jump to their sections.

## 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/nav.css';
```

Name the landmark with `aria-label`, put each link in an `<li>` of a `<ul>`, and give each group a `<details>` with a `<summary>` and a nested list. Groups of one list share a `name`. Section labels are `<h2>` or `<h3>` children of the nav.

Mark the current link with `aria-current="page"` from the route, and render `open` on the group that holds it, on the server: the first paint is then complete. Search, pinning and a customised order are the application’s; a button beside a link is the `actions` part.

`data-slean-orientation="horizontal"` draws the links as tabs for the sections of one page; they stay links. `data-slean-collapsed` is the icon rail: add `data-slean-hint` to its links and render each group as a button and a hover card while it is collapsed (example above).

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<nav aria-label="…" data-slean="nav">` | – | yes | A named navigation landmark. Options: data-slean-orientation (vertical, horizontal), data-slean-collapsed, data-slean-scrollspy. |
| list | `<ul> of <li>` | – | yes | Each \<li\> holds one row; several lists may follow one another, each after its label. |
| link | `<a href> in an <li>` | – | yes | aria-current="page" on the current one. A link that cannot be followed is \<a aria-disabled="true"\> without href. |
| group | `<details name> with a <summary> and a nested <ul>` | – | no | Groups of one list share a name, so one is open at a time. Render open on the group holding the current page. |
| icon | `<span> around an SVG, first in a row` | `icon` | no | Decorative (aria-hidden on the SVG). Required in the rail, where it is all that shows. |
| group label | `<h2> or <h3> child of the root, or any element with the attribute` | `group-label` | no | A section label before its list. Matched by element first. |
| actions | `a button (or a wrapper) beside the link in its <li>` | `actions` | no | Pin, more: over the row’s inline end, never inside the link. Hidden in the rail. |
| badge | `<span data-slean="badge"> inside a link or summary` | – | no | Pushed to the row’s end; in the rail, to the corner of the square. Part of the link’s name. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab`/`Shift+Tab` | anywhere | Moves between links and group summaries (native) |
| `Enter` | focus on a link | Follows it (native) |
| `Enter`/`Space` | focus on a summary | Opens or closes the group; opening one closes the others of its name (native) |
| `Tab` | onto a rail row | Shows the link’s hint, or the group’s hover card (hint, hover card) |
| `Escape` | a hint or card is shown | Hides it (hint, hover card) |

## Styling

`nav.css` draws each row (link, summary or rail button) at the medium control height with an inset focus ring, fills the current link with the soft tint of the current color, marks the group around it through `:has()`, turns the summary’s chevron, hangs a nested list under its summary with a guide line and animates a group’s height where `::details-content` exists. The horizontal strip draws line tabs over a baseline and scrolls sideways; the rail clips each label after its icon and moves a badge to the corner. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | gap, padding-block, inset-inline-end |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-leading` | `1.5` | line-height |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-block, padding-inline, gap, margin-inline-start |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-typography-overline-size` | `0.75rem` | font-size |
| `--slean-font-weight-semibold` | `600` | font-weight |
| `--slean-typography-overline-line-height` | `2` | line-height |
| `--slean-typography-overline-transform` | `uppercase` | text-transform |
| `--slean-control-height-md` | `2.25rem` | min-block-size, inset-block-start |
| `--slean-radius-md` | `var(--slean-radius)` | border-radius |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-icon-chevron-down` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-duration-normal` | `160ms` | transition |
| `--slean-control-height-sm` | `2rem` | padding-inline-end |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | padding-inline-end, padding-inline-start, gap, margin-block |
| `--slean-muted` | `var(--slean-neutral-3)` | background |
| `--slean-color-soft` | `var(--slean-primary-soft)` | background |
| `--slean-color-soft-text` | `var(--slean-primary-soft-text)` | 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-width` | `2px` | outline-offset |
| `--slean-border` | `var(--slean-neutral-6)` | border-inline-start, box-shadow, background |
| `--slean-color-main` | `var(--slean-primary-main)` | border-block-end-color, color |
| `--slean-control-height-lg` | `2.75rem` | inline-size, min-block-size, padding-inline, gap |

State selectors the stylesheet targets, all from the platform or ARIA: `::details-content`, `:first-child`, `:focus-visible`, `:hover`, `:target-current`, `[aria-current="page"]`, `[aria-disabled="true"]`, `[href]`, `[open]`.

Variant attributes: `data-slean-scrollspy`; `data-slean-collapsed`; `data-slean-orientation` (`horizontal`).

## 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>`, `<details name>`, `aria-current="page"`, `:has()`, `::details-content`, `scroll-target-group`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<nav> + <ul> of links + <details name> groups + aria-current="page"`

Tier 0: no behavior module. The Vite plugin maps `nav` to nothing; a sidebar ships no Svelte Lean JavaScript. The rail’s hints and flyouts are the hint and hover card primitives, whose modules are injected only where their attributes are in the markup.

Why this implementation

A sidebar is links, and groups of links that open and close. The platform has both: links in a named `<nav>`, and `<details>` for each group, whose `<summary>` is a disclosure button with its expanded state. Groups that share a `name` open one at a time, natively. This is the WAI-ARIA disclosure navigation pattern; an ARIA menu would take the links out of the tab order and ask for arrow keys that site navigation does not use.

What markup alone gets wrong is the look of the current page and of its group. `nav.css` reads `aria-current="page"` for the link and `:has()` for the group around it, so a closed group still shows that it holds the current page, with no state copied into script.

### The browser owns

- the navigation landmark and its name
- the links, their focus order and Enter
- opening and closing a group, one at a time per name (\<details name\>), and its expanded state
- which link is current, through aria-current="page" written from the route
- with data-slean-scrollspy, the link of the section in view (:target-current, Chromium)

### Svelte Lean owns

- nav.css: the rows, the current page’s fill, the group around it marked through :has(), the chevron and the guide line of a nested list
- the horizontal strip of line tabs that scrolls sideways
- the icon rail (data-slean-collapsed): square rows that clip their labels, badges in the corner, section labels as rules
- the rail’s composition with the hint and the hover card, documented here
- the contract

Accessibility

- A named `<nav>` landmark; several on a page each have their own name.
- Every link and summary is in the tab order; no arrow keys and no roving focus, as in the disclosure navigation pattern.
- `<summary>` exposes the group’s expanded state; `aria-current="page"` the current link.
- In the rail the labels are clipped, not removed: each row keeps its text as its accessible name, and the hint repeats it on screen.
- Horizontal links are links: no `tablist` or `tab` roles.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<nav>, <ul>, <details>, <summary>, aria-current` | Widely available | Not applicable |
| `<details name>` | Baseline 2024 (Chrome 120, Safari 17.2, Firefox 130) | Every group opens independently |
| `:has()` | Baseline 2023 (Chrome 105, Safari 15.4, Firefox 121) | A closed group does not show that it holds the current page |
| `::details-content, interpolate-size` | ::details-content Baseline 2025; interpolate-size Chrome 129 | A group opens at once, without the height transition |
| `scroll-target-group, :target-current` | Chrome 140 | With data-slean-scrollspy, no link is marked; the links still work |
| `Interest invokers (interestfor), invoker commands` | Chrome 142; invoker commands Baseline 2025 | The rail’s hover card runs on the fallback listeners; a click shows it |

Without JavaScript

Fully functional: the links, the groups and their one-at-a-time opening are native. In the rail a hint does not show; a group’s card opens on a click through invoker commands, and on hover where interest invokers ship.

Server rendering and hydration

Static markup. Render `aria-current` and the open group from the route; the rail’s state from a cookie or a preference.

Nothing is attached to the nav; it works before and after hydration alike.

Compatibility

The elements and `aria-current` are widely available. One group open at a time needs `<details name>` (Baseline 2024); the group mark needs `:has()` (Baseline 2023); the height transition needs `::details-content` and `interpolate-size` (Chromium). `data-slean-scrollspy` needs `scroll-target-group` and `:target-current` (Chrome 140).

Tests and source

- `apps/playground/tests/primitives/nav.spec.ts` Playwright: groups, the current page, link tabs, the rail with hints and hover cards, the scroll target group, with axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

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