Navigation
Stepper
Shows where someone is in a form of several steps, and lets them go back.
Installation
npx sinaui add stepperCopy src/lib/ui/Stepper.svelte, Icon.svelte and tokens.css. No dependencies.
Examples
A borrowing request
Finished steps can be chosen to go back; steps ahead can't be skipped to. Narrow the window to see the compact form.
Reader
<script lang="ts">
import { fly } from 'svelte/transition';
import Button from '#lib/ui/Button.svelte';
import Input from '#lib/ui/Input.svelte';
import Stepper from '#lib/ui/Stepper.svelte';
const steps = [
{ label: 'Reader', description: 'Who is borrowing' },
{ label: 'Manuscript', description: 'What to copy' },
{ label: 'Collection', description: 'When and where' },
{ label: 'Review', description: 'Check and send' }
];
let current = $state(0);
let direction = $state(1);
let heading = $state<HTMLHeadingElement>();
function go(to: number) {
direction = to > current ? 1 : -1;
current = to;
// Screen readers hear the new step's heading; keyboard users carry on from there.
requestAnimationFrame(() => heading?.focus());
}
</script>
<div class="flow">
<Stepper {steps} {current} onstep={go} label="Borrowing request" />
<!-- The step's content slides in from the side it's coming from. -->
<div class="panel">
{#key current}
<section in:fly={{ x: 24 * direction, duration: 250 }}>
<h3 bind:this={heading} tabindex="-1">{steps[current].label}</h3>
{#if current === 0}
<Input label="Name" value="Maryam al-Ijliya" />
{:else if current === 1}
<Input label="Manuscript" value="Book of Optics" />
{:else if current === 2}
<Input label="Reading room" value="Bayt al-Ḥikma, Baghdad" />
{:else}
<p>Maryam al-Ijliya · Book of Optics · Bayt al-Ḥikma, Baghdad</p>
{/if}
</section>
{/key}
</div>
<div class="actions">
<Button variant="ghost" disabled={current === 0} onclick={() => go(current - 1)}>Back</Button>
{#if current < steps.length - 1}
<Button onclick={() => go(current + 1)}>Continue</Button>
{:else}
<Button onclick={() => go(0)}>Send request</Button>
{/if}
</div>
</div>
<style>
.flow {
display: grid;
gap: 1.5rem;
inline-size: 100%;
/* Fields and buttons share one column, so Continue lines up with the field's edge. */
max-inline-size: 36rem;
}
/* Clips the slide, with room around it so focus rings inside aren't cut off. */
.panel {
display: grid;
overflow: clip;
padding: 6px;
margin: -6px;
}
section {
grid-area: 1 / 1;
display: grid;
gap: 0.75rem;
}
h3 {
margin: 0;
font-size: 1rem;
}
h3:focus {
outline: none;
}
p {
margin: 0;
color: var(--ui-muted);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>Down the side, with a problem
Vertical keeps names and descriptions beside their markers, for sidebars and narrow columns. A step with an error shows it in place of its description.
<script lang="ts">
import Button from '#lib/ui/Button.svelte';
import Stepper from '#lib/ui/Stepper.svelte';
let current = $state(2);
let problem = $state(true);
const steps = $derived([
{ label: 'Request received', description: 'Al-Qarawiyyin, Fez' },
{ label: 'Copyist assigned', description: 'Lubna of Córdoba' },
{
label: 'Paper and ink',
description: 'From the Samarkand mill',
error: problem ? 'The paper order was returned. Choose another mill.' : undefined
},
{ label: 'Copy made', description: 'About forty days' },
{ label: 'Sent', description: 'By the Fez caravan' }
]);
</script>
<div class="demo">
<Stepper {steps} {current} orientation="vertical" label="Copy of the Book of Optics" />
<div class="actions">
{#if problem}
<Button variant="secondary" onclick={() => (problem = false)}>Choose another mill</Button>
{:else}
<Button variant="secondary" disabled={current >= steps.length} onclick={() => current++}
>Next step</Button
>
<Button variant="ghost" onclick={() => ((current = 2), (problem = true))}>Start over</Button>
{/if}
</div>
</div>
<style>
.demo {
display: grid;
gap: 1.5rem;
inline-size: min(22rem, 100%);
}
.actions {
display: flex;
gap: 0.5rem;
}
</style>The Stepper shows progress; your page shows each step's content. The example slides content in the direction of travel and moves focus to the new step's heading, so screen readers announce it.
Props
| Prop | Type | Default |
|---|---|---|
steps | { label, description?, error? }[] | |
current | number, bindable | 0 |
onstep | (index) => void | steps can't be chosen |
label | string | 'Progress' |
orientation | 'horizontal' | 'vertical' | 'horizontal' |
Accessibility
- A navigation landmark around an ordered list; the current step carries
aria-current="step". - Each step is read with its place and state: “Step 2 of 4: Manuscript, current”.
- Finished steps are buttons; steps not reached yet are plain text.
- A step with an error is read with it: “Step 3 of 5: Paper and ink, needs attention: …”.