Primitives Navigation
Steps
An ordered list of the steps of a task. The current step carries aria-current="step"; a complete step contains text that says so, which the stylesheet also reads to draw a check. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
<ol>,aria-current="step",:has(),CSS counters- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
<ol> + aria-current="step"
On this page
Example
- Cart Completed
- Shipping Address and delivery date
- Payment Card or invoice
- Review
<ol data-slean="steps" aria-label="Checkout">
<li>
<span data-slean-part="title">Cart</span>
<span data-slean-part="complete">Completed</span>
</li>
<li aria-current="step">
<span data-slean-part="title">Shipping</span>
<span data-slean-part="description">Address and delivery date</span>
</li>
<li>
<span data-slean-part="title">Payment</span>
<span data-slean-part="description">Card or invoice</span>
</li>
<li><span data-slean-part="title">Review</span></li>
</ol><script lang="ts">
// Nothing to import at runtime: the steps are a list. The stylesheet is optional.
import '@svelte-lean/styles/steps.css';
</script>
<ol data-slean="steps" aria-label="Checkout">
<li>
<span data-slean-part="title">Cart</span>
<span data-slean-part="complete">Completed</span>
</li>
<li aria-current="step">
<span data-slean-part="title">Shipping</span>
<span data-slean-part="description">Address and delivery date</span>
</li>
<li>
<span data-slean-part="title">Payment</span>
<span data-slean-part="description">Card or invoice</span>
</li>
<li><span data-slean-part="title">Review</span></li>
</ol>Why this implementation exists
A stepper is an ordered list with a current item: the <ol> gives each step its position (“2 of 4”) and aria-current="step" marks where the reader is. Neither needs a script.
The state that markup usually loses is “complete”: a check drawn for sighted readers and nothing for a screen reader. Here the complete state is one element, a text that says “Completed”, hidden visually and read aloud; steps.css draws the check from that same element with :has(), so what is seen and what is heard cannot disagree.
The browser owns
- the ordered list and each step’s position
- the current step state (aria-current)
- the counter behind each number
Svelte Lean owns
- steps.css: the indicators, the connectors, the current and complete states
- the horizontal and vertical orientations
- the contract: one element that both says and shows that a step is complete
- documentation
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@svelte-lean/styles/steps.css';Render one <li> per step, aria-current="step" on the current one, and the complete part in every step that is done. Write the complete text in the language of the page.
A step that can be revisited holds a link. When the steps navigate a multi-page form, wrap the list in a named <nav>.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <ol data-slean="steps"> | – | yes | data-slean-orientation="horizontal|vertical". Named with aria-label when useful. |
| step | <li> | – | yes | aria-current="step" on the current one. |
| title | <span> or <a href> | title | styles only | The step’s name. |
| description | <span> | description | no | A second, smaller line. |
| complete | <span> with text | complete | yes | In every complete step. Read by assistive technology, hidden visually; its presence draws the check. |
Runtime profile
Tier 0: the steps have no behavior module, the Vite plugin maps steps to no module, and the page ships no Svelte Lean JavaScript for them.
Accessibility contract
- An
<ol>: screen readers announce each step’s position. aria-current="step"on the current step.- The
completepart is text, clipped to one pixel and read after the step’s title. - The step number is generated content with empty alternative text, so the position is not read twice.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab/Shift+Tab | links inside the steps | Moves between them (native); the steps are not focusable |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
aria-current="step", CSS counters | Widely available | Not applicable |
:has() | Baseline 2023 | A complete step looks like an upcoming one; its text is still read |
Alternative text in content | Baseline 2024 | A screen reader may read the number with the step |
rotate | Baseline 2022 | The check is drawn unturned |
Without JavaScript
Fully functional: the list and its states are markup.
Server rendering
Static markup: the server renders the current and complete states from the task’s state.
Before hydration
Nothing is attached; the list is the same before and after hydration.
Styling
steps.css numbers the steps with a counter in a ring (li::before), draws the connector to the next step (li::after), fills the current step’s ring with the accent, and draws a check in the soft accent for a step that contains the complete part, from two gradients in the turned ring. data-slean-orientation="vertical" moves the connector below the ring. Under forced colors the rings and connectors use system colors. 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-space-2 | 0.5rem | padding-block-start, inset-inline-start, inset-inline-end |
--slean-space-4 | 1rem | padding-inline-end |
--slean-border-strong | var(--slean-neutral-8) | border |
--slean-surface | oklch(100% 0 0) | background-color |
--slean-text-xs | 0.75rem | font-size |
--slean-font-weight-semibold | 600 | font-weight |
--slean-border | var(--slean-neutral-6) | background |
--slean-font-weight-medium | 500 | font-weight |
--slean-fg | var(--slean-neutral-12) | color |
--slean-accent | oklch(54% 0.19 258) | border-color, background-color, background |
--slean-accent-fg | oklch(100% 0 0) | color |
--slean-icon-check | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask-image |
--slean-space-5 | 1.25rem | padding-block |
--slean-space-3 | 0.75rem | padding-inline |
--slean-space-1 | 0.25rem | inset-block |
State selectors the stylesheet targets, all from the platform or ARIA: :last-child, [aria-current="step"].
Variant attributes: data-slean-orientation (vertical).
Controlled integration
The application owns the step index; the markup follows it. Every step before the current one gets the complete part.
<script lang="ts">
// The application owns the step index; the markup follows it.
let { current }: { current: number } = $props();
const steps = ['Cart', 'Shipping', 'Payment', 'Review'];
</script>
<ol data-slean="steps" aria-label="Checkout">
{#each steps as title, i (title)}
<li aria-current={i === current ? 'step' : undefined}>
<span data-slean-part="title">{title}</span>
{#if i < current}<span data-slean-part="complete">Completed</span>{/if}
</li>
{/each}
</ol>Compatibility notes
The list, aria-current and counters are widely available. :has() (Baseline 2023) draws the complete state; without it a complete step looks like an upcoming one and its text is still read. Alternative text for generated content is Baseline 2024; without it a screen reader may read the number with the step. The check is turned with the rotate property (Baseline 2022).
Examples
Vertical
data-slean-orientation="vertical" stacks the steps with the connector below each indicator.
It suits long titles, descriptions and narrow columns; the semantics do not change.
- Create the workspace harbor.studio Completed
- Invite the team Four people joined Completed
- Connect a repository Pull requests appear in the review queue
- Plan the first sprint
<ol data-slean="steps" data-slean-orientation="vertical" aria-label="Onboarding">
<li>
<span data-slean-part="title">Create the workspace</span>
<span data-slean-part="description">harbor.studio</span>
<span data-slean-part="complete">Completed</span>
</li>
<li>
<span data-slean-part="title">Invite the team</span>
<span data-slean-part="description">Four people joined</span>
<span data-slean-part="complete">Completed</span>
</li>
<li aria-current="step">
<span data-slean-part="title">Connect a repository</span>
<span data-slean-part="description">Pull requests appear in the review queue</span>
</li>
<li><span data-slean-part="title">Plan the first sprint</span></li>
</ol>Testing
apps/playground/tests/primitives/feedback-navigation.spec.tsPlaywright: roles, names and states in the accessibility tree, keyboard, RTL, reduced motion, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/steps/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/steps/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/steps.cssthe optional stylesheet