# Steps

> An ordered list of the steps of a task with aria-current="step".

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

An ordered list that shows where the reader is in a multi-step task, such as a checkout or an onboarding. Tier 0.

```svelte
<script lang="ts">
	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>
```

Markup and CSS only; it works with page JavaScript disabled.

## Examples

### Wizard

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

	const steps = ['Account', 'Profile', 'Plan', 'Confirm'];
	let current = $state(1);
</script>

<ol data-slean="steps" aria-label="Sign-up">
	{#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>
<div class="actions">
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral"
		disabled={current === 0} onclick={() => current--}>
		Back
	</button>
	<button type="button" data-slean="button"
		disabled={current === steps.length} onclick={() => current++}>
		{current < steps.length - 1 ? 'Next' : 'Finish'}
	</button>
</div>

<style>
	.actions {
		display: flex;
		justify-content: flex-end;
		gap: 0.5rem;
		margin-top: 1.5rem;
	}
</style>
```

The application owns the index; every step before it gets the complete part.

### Vertical

```svelte
<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>
```

For long titles, descriptions and narrow columns.

### Error state

```svelte
<ol data-slean="steps" aria-label="New campaign">
	<li>
		<span data-slean-part="title">Details</span>
		<span data-slean-part="complete">Completed</span>
	</li>
	<li>
		<span data-slean-part="title">Audience</span>
		<span data-slean-part="description">Two fields to fix</span>
		<span data-slean-part="error">Has errors</span>
	</li>
	<li aria-current="step"><span data-slean-part="title">Schedule</span></li>
	<li><span data-slean-part="title">Review</span></li>
</ol>
```

The error part is read after the title and draws a warning over any other state.

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

One `<li>` per step, `aria-current="step"` on the current one, and the `complete` part in every finished step, written 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. |
| error | `<span> with text` | `error` | no | In a step with a problem. Hidden visually and read ("Has errors"); its presence draws the marker as a warning in the error color. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab`/`Shift+Tab` | links inside the steps | Moves between them (native); the steps are not focusable |

## 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 ring with the current color and draws a check in a complete one. `data-slean-orientation="vertical"` moves the connector below the ring; under forced colors rings and connectors use system colors. `data-color` on the root or an ancestor colors the current and complete steps (primary by default). 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` | `calc(var(--slean-spacing) * 2)` | padding-block-start, inset-inline-start, inset-inline-end |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | 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-color-main` | `var(--slean-primary-main)` | border-color, background-color, background |
| `--slean-color-contrast-text` | `var(--slean-primary-contrast-text)` | 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-error-main` | `oklch(52% 0.19 25)` | background-color |
| `--slean-icon-warning` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'%3E%3Cpath d='M6.7 2.75a1.5 1.5 0 012.6 0l5.1 8.9a1.5 1.5 0 01-1.3 2.25H2.9a1.5 1.5 0 01-1.3-2.25z'/%3E%3Cpath d='M8 6.25v3M8 11.5v.01'/%3E%3C/g%3E%3C/svg%3E")` | mask-image |
| `--slean-space-5` | `calc(var(--slean-spacing) * 5)` | padding-block |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | inset-block |

State selectors the stylesheet targets, all from the platform or ARIA: `:last-child`, `[aria-current="step"]`.

Variant attributes: `data-slean-orientation` (`vertical`).

## 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**: `<ol>`, `aria-current="step"`, `:has()`, `CSS counters`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<ol> + aria-current="step"`

Tier 0: no behavior module; the Vite plugin maps `steps` to nothing and the page ships no Svelte Lean JavaScript for them.

Why this implementation

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.

Markup usually loses the complete state: a check drawn for sighted readers and nothing for a screen reader. Here it is one element, a “Completed” text hidden visually and read aloud; `steps.css` draws the check from that 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

Accessibility

- An `<ol>`: screen readers announce each step’s position.
- `aria-current="step"` on the current step.
- The `complete` and `error` parts are 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.

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 |
| `mask-image with mask-composite` | Baseline 2023 | The check and the warning are not cut out of the disc |

Without JavaScript

Fully functional: the list and its states are markup.

Server rendering and hydration

Static markup: the server renders the current and complete states from the task’s state.

Nothing is attached; the list is the same before and after hydration.

Compatibility

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 and the warning are icon masks cut out of the disc (`mask-composite: exclude`, Baseline 2023).

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/steps/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/steps/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/steps.css` the optional stylesheet
