Stepper
Multi-step progress and completion control.
Live demo
01Read-only horizontal
- Cart
- Shipping
- 3 Payment
- 4 Review
Interactive (clickable + buttons)
- 3 Payment
- 4 Review
Current step index: 1 (Shipping)
Vertical orientation
- Account
- Verify email
- 3 Profile
- 4 Preferences
- 5 Done
Custom palette
- Plan
- 2 Build
- 3 Ship
All-done success state
Setting currentStep past the final index marks every step done β useful for the βwizard finishedβ screen.
- Build
- Test
- Stage
- Ship
All four stages have completed. Good work, team.
Implementation
02<script lang="ts">
import Stepper from '$lib/components/Stepper.svelte';
let step = $state(1);
const steps = ['Cart', 'Shipping', 'Payment', 'Review'];
</script>
β
<Stepper {steps} currentStep={step} clickable onSelect={(i) => (step = i)} />Stepper derives done / current / pending states from a single currentStep index, so parent state stays trivially small. The checkmark on completed steps is inline SVG (no icon font) and the orientation prop simply swaps the connector axis between row and column. With clickable enabled, completed and current steps become buttons that call onSelect, while pending steps stay locked so users can't skip ahead.
Logic explainer
03What Does It Do? (Plain English)
A compact visual indicator for multi-stage flows β checkout, onboarding wizards, multi-page forms. Each step has one of three states (done, current, pending) and they're joined by connector lines, so users always have a "you are here on a 4-step journey" cue. Optional clickable mode turns done and current steps into jump-to-step navigation; pending steps stay locked so users can't skip ahead before finishing what they're on.
Think of it as the metro-line diagram on a Tube map: you can see every station on the line, the one you've reached, and the ones still ahead. The dots and lines tell you where you've been, where you are, and how much is left.
How It Works (Pseudo-Code)
state:
steps[] = string labels (length determines step count)
currentStep = index of the active step (0-based)
clickable = whether done/current steps fire onSelect when clicked
derive per step i:
state = i < currentStep ? 'done'
: i === currentStep ? 'current'
: 'pending'
color = state === 'done' ? doneColor
: state === 'current' ? activeColor
: pendingColor
render:
for each step i:
if clickable && state !== 'pending':
<button onclick={onSelect(i)}>circle + label</button>
else:
<div>circle + label</div>
circle:
'done' β filled badge with checkmark SVG
'current' β outlined badge with number, active colour
'pending' β outlined badge with number, dimmed
if i < steps.length - 1:
connector line, coloured 'done' or 'pending'
events:
on click step (clickable && !pending):
fire onSelect(index)
parent updates currentStepThe states are derived from currentStep, not stored separately β pass currentStep = 2 and steps 0 and 1 are done, step 2 is current, steps 3+ are pending. There's no separate "completion" state to track.
The Core Concept: Three States Derived From One Cursor
A naΓ―ve stepper stores a done boolean per step. That works but breaks the moment you need backwards navigation: clicking step 1 from step 3 should mark steps 2 and 3 as not done anymore, and the consumer has to remember to update three booleans atomically.
This component stores one number β currentStep β and derives state from position:
state(i) = i < currentStep ? 'done'
: i === currentStep ? 'current'
: 'pending'Backwards navigation just decrements currentStep. Forwards navigation increments. Skipping a step in code is a no-op for the data model β currentStep = 5 automatically marks steps 0β4 as done. The whole state machine is one cursor moving left/right, which is the right mental model for a linear flow.
The trade-off: this component models linear flows only. Branching flows (step 2A or step 2B based on a choice) need a richer state model β at that point you've outgrown a Stepper.
Connector Colour Logic
Each step renders a connector line after itself unless it's the last:
if i < steps.length - 1:
connector colour = state === 'done' ? doneColor : pendingColorNote: the connector colour follows the step before it, not the step after. That means the connector between step 2 (done) and step 3 (current) is doneColor β visually showing "this part of the journey is complete". The connector between step 3 (current) and step 4 (pending) is pendingColor β "you haven't crossed this yet".
This subtle rule produces a clean, intuitive read: the line behind you is bright, the line ahead is dim, with the breakpoint at the current step.
Click-to-Jump With a Locked Future
When clickable is true, only done and current steps render as <button>s. Pending steps render as plain <div>s with no click handler. The reasoning:
- Going back: clicking step 1 from step 3 lets users review or correct earlier input. This is desired in checkout flows β "wait, was that the right address?"
- Skipping ahead: clicking step 5 from step 1 would let users bypass validation on steps 2β4. That's almost always wrong. If the parent wants to allow it, they can listen to
onSelectand decide for themselves; but locking pending steps at the component level makes the safe default safe.
The aria-current="step" attribute is set on the current step's <li>, so AT users hear "current step, 2 of 5: Shipping". Each step has aria-label="Step N of M: Label" for context.
State Flow Diagram
currentStep = 2 (step 3, 0-indexed: 'Payment'):
ββββββββ ββ ββββββββ ββ ββββββββ ββ ββββββββ ββ ββββββββ
β β β β β β β 3 β β 4 β β 5 β
β done β β done β β curr β β pend β β pend β
ββββββββ ββββββββ ββββββββ ββββββββ ββββββββ
Cart Address Payment Review Confirm
click step 0 (clickable mode, state='done'):
onSelect(0) fires
parent sets currentStep = 0
re-derive: step 0 = current, 1+ = pending
stepper re-renders left-to-right with the new cursor
click step 4 (clickable mode, state='pending'):
no <button>, no click handler
nothing happensProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
steps |
string[] |
[] |
Step labels. Length determines step count. |
currentStep |
number |
0 |
Index of the current step (0-based). |
orientation |
'horizontal' | 'vertical' |
'horizontal' |
Layout direction. |
clickable |
boolean |
false |
Make done and current steps clickable for jump-to-step navigation. |
onSelect |
(index: number) => void |
undefined |
Fires when a clickable step is activated. |
activeColor |
string |
'#3b82f6' |
Current step ring + label colour. |
doneColor |
string |
'#22c55e' |
Completed step badge fill colour. |
pendingColor |
string |
'#cbd5e1' |
Pending step ring colour. |
class |
string |
'' |
Extra classes on the <ol>. |
Edge Cases
| Situation | Behaviour |
|---|---|
currentStep > steps.length - 1 |
Every step renders as done (no current, no pending). The badge of step N-1 still shows the checkmark. Useful for "all done" success screens. |
currentStep < 0 |
Every step renders as pending. Nothing is current. |
Single-step flow (steps.length === 1) |
One badge, no connector. State is current if currentStep === 0, else done or pending. |
clickable but onSelect omitted |
Steps still render as <button>s for keyboard parity; clicks are no-ops. |
currentStep set to a pending step's index by parent (jumped ahead) |
The step becomes current; preceding steps that weren't done before are now done retrospectively. Linear cursor model means there's no "you skipped" detection β the parent owns gating. |
User has prefers-reduced-motion: reduce |
Step-marker colour transitions and label transitions are removed; state changes are instant. |
| Vertical orientation on a narrow screen | Steps stack top-to-bottom; connectors become vertical lines. Connector min-height ensures visible spacing even with short labels. |
| Step labels longer than the badge width allows | Labels truncate with ellipsis (overflow: hidden; text-overflow: ellipsis) on horizontal; full-width on vertical. Keep labels short β "Shipping", not "Enter your shipping address". |
Dependencies
- Svelte 5.x β
$props,{@const}for per-iteration derived values. The state derivation is a single function; the rendering is a{#each}loop. - Zero external dependencies. Inline SVG checkmark, scoped CSS, no icon library.
File Structure
src/lib/components/Stepper.svelte # implementation
src/lib/components/Stepper.md # this file (rendered inside ComponentPageShell)
src/lib/components/Stepper.test.ts # vitest unit tests
src/routes/stepper/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
steps | string[] | [] | Required. Step labels in order. |
currentStep | number | 0 | Index of the active step (zero-based). |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Layout direction. |
clickable | boolean | false | Allow jumping back to completed steps. |
onSelect | (index) => void | β | Fires when a clickable step is activated. |
activeColor / doneColor / pendingColor | string | Brand defaults | Custom palette per state. |