Stepper
Multi-step flows with validation that waits, steps that cannot be skipped, and focus that follows.
pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-stepper.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-stepper.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-stepper.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-stepper.jsonA wizard is a currentStep number until it has to validate. Then Next needs to wait on a request
and stay put when it fails, the step list has to refuse a jump past a step nobody filled in, and after
Next the focus is on a button that may no longer exist. For a keyboard user, that's the page body.
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
import Stepper from '#lib/components/coral/kit/stepper/stepper.svelte';
import StepperContent from '#lib/components/coral/kit/stepper/stepper-content.svelte';
import StepperItem from '#lib/components/coral/kit/stepper/stepper-item.svelte';
import StepperList from '#lib/components/coral/kit/stepper/stepper-list.svelte';
import StepperNext from '#lib/components/coral/kit/stepper/stepper-next.svelte';
import StepperPrevious from '#lib/components/coral/kit/stepper/stepper-previous.svelte';
import { Badge } from '#lib/components/ui/badge/index.js';
const steps = ['repository', 'build', 'deploy'];
const titles: Record<string, string> = {
repository: 'Repository',
build: 'Build',
deploy: 'Deploy'
};
let current = $state<string>();
let finished = $state(false);
</script>
<div class="flex w-full max-w-md flex-col gap-4">
<Stepper {steps} bind:value={current} onfinish={() => (finished = true)}>
<StepperList aria-label="Deployment steps">
{#each steps as step, index (step)}
<StepperItem {step}>
{#snippet children({ state })}
<Badge variant={state === 'upcoming' ? 'outline' : 'default'}>
{#if state === 'complete'}<CheckIcon />{:else}{index + 1}{/if}
</Badge>
<span class="text-sm">{titles[step]}</span>
{/snippet}
</StepperItem>
{/each}
</StepperList>
{#each steps as step (step)}
<StepperContent {step} class="rounded-lg border p-4 text-sm">
Content for the "{titles[step]}" step.
</StepperContent>
{/each}
<div class="flex justify-between">
<StepperPrevious>Back</StepperPrevious>
<StepperNext>
{#snippet children({ isLast })}{isLast ? 'Finish' : 'Next'}{/snippet}
</StepperNext>
</div>
</Stepper>
{#if finished}<p class="text-sm text-muted-foreground" role="status">
Deployment queued for production.
</p>{/if}
</div>What Coral adds
- Validation that waits:
onbeforenextruns on Next with the step being left. Return a promise and the stepper waits; returnfalseor throw and it stays. The Next button reports it is busy and keeps focus. - Linear by completion: a step can be jumped to once every step before it is complete. After going back to fix step 1, step 4 is still one click away.
- Focus follows. After Next or Previous, focus moves to the new panel, which is named by its step, so a screen reader says where the reader landed. Only when focus was inside the stepper.
aria-current="step"on the current step, and an ordered list, so position is announced for free.- Arrow keys between steps, on top of Tab. Right-to-left aware.
Validation
Try «My Service».
<script lang="ts">
import Stepper from '#lib/components/coral/kit/stepper/stepper.svelte';
import StepperContent from '#lib/components/coral/kit/stepper/stepper-content.svelte';
import StepperItem from '#lib/components/coral/kit/stepper/stepper-item.svelte';
import StepperList from '#lib/components/coral/kit/stepper/stepper-list.svelte';
import StepperNext from '#lib/components/coral/kit/stepper/stepper-next.svelte';
import StepperPrevious from '#lib/components/coral/kit/stepper/stepper-previous.svelte';
import { Input } from '#lib/components/ui/input/index.js';
import { Label } from '#lib/components/ui/label/index.js';
const steps = ['project', 'environment', 'review'] as const;
type Step = (typeof steps)[number];
const titles: Record<Step, string> = {
project: 'Project',
environment: 'Environment',
review: 'Review'
};
let slug = $state('');
let error = $state('');
// Simulates checking the slug against a server before letting the reader move on.
async function beforeNext(step: Step) {
error = '';
if (step !== 'project') return;
await new Promise((resolve) => setTimeout(resolve, 700));
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
error = 'Use lowercase letters, numbers and hyphens.';
return false;
}
}
</script>
<div class="flex w-full max-w-md flex-col gap-4">
<Stepper {steps} onbeforenext={beforeNext}>
<StepperList aria-label="New project setup">
{#each steps as step, index (step)}
<StepperItem {step} class="rounded-md px-2 py-1 data-[state=current]:bg-muted">
<span class="text-sm">{index + 1}. {titles[step]}</span>
</StepperItem>
{/each}
</StepperList>
<StepperContent step="project" class="flex flex-col gap-2">
<Label for="slug">Project slug</Label>
<Input id="slug" bind:value={slug} aria-invalid={error ? 'true' : undefined} />
<p class="text-sm text-muted-foreground" role="status">{error || 'Try «My Service».'}</p>
</StepperContent>
<StepperContent step="environment" class="text-sm">Production or preview.</StepperContent>
<StepperContent step="review" class="text-sm">All set.</StepperContent>
<div class="flex justify-between">
<StepperPrevious>Back</StepperPrevious>
<StepperNext>
{#snippet children({ isLast, pending })}
{pending ? 'Validating…' : isLast ? 'Create project' : 'Next'}
{/snippet}
</StepperNext>
</div>
</Stepper>
</div>Next on the last step runs onbeforenext, then onfinish, under the same false convention.
Steps are whatever you pass (strings, ids, the step objects themselves), compared with ===. Prefer
ids when binding value: an object assigned into a $state comes back wrapped in a proxy, which is
no longer === to the object in steps. Bind to $state.raw if the steps must be objects.
Composition
Stepper
├── StepperList
│ └── StepperItem ×n → button, children({ state, index, disabled })
├── StepperContent ×n → shown for its step
├── StepperPrevious
└── StepperNext → children({ isLast, pending })Stepper's own children snippet receives the shared state (value, next(), goTo(), stateOf()) for a layout the pieces do not cover.
One form across steps
StepperContent unmounts the panels that are not showing, and an unmounted field is not submitted.
When every step's fields post in a single <form>, set keepMounted: inactive panels stay in the page
with hidden. Validate each step in onbeforenext: the browser cannot focus a required field
inside a hidden panel to report it.
Import
<script lang="ts">
import Stepper from '#lib/components/coral/kit/stepper/stepper.svelte';
import StepperItem from '#lib/components/coral/kit/stepper/stepper-item.svelte';
// ...one import per piece you use
</script>Props
Stepper
| Prop | Type | Default | Description |
|---|---|---|---|
steps | T[] | - | Every step, in order. Required. |
value | T | first step | Bindable. |
completed | T[] | [] | Bindable. Steps finished with Next. |
linear | boolean | true | Refuses jumping past an incomplete step. |
onbeforenext | (step: T) => unknown | - | Async-aware. false or throw stays. |
onfinish | () => unknown | - | Next on the last step. |
onerror | (error: unknown) => void | - | What onbeforenext or onfinish threw. |
onvaluechange | (step: T) => void | - | The current step changed. |
orientation | 'horizontal' | 'vertical' | horizontal | Arrow keys and layout direction. |
children | Snippet<[StepperContext<T>]> | - | The pieces, with the shared state. |
StepperItem
| Prop | Type | Description |
|---|---|---|
step | T | Which step. Required. |
itemClass | string | Merged onto the <li>. |
children | Snippet<[{ state, index, disabled }]> | What the step shows. Required. |
state is complete, current or upcoming, also exposed as data-state for styling.
StepperContent
| Prop | Type | Default | Description |
|---|---|---|---|
step | T | - | Which step. Required. |
keepMounted | boolean | false | Keeps inactive panels in the page, hidden. |
StepperPrevious
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | Previous | The label, when the children are left out. |
children | Snippet | - | Replaces the label. |
StepperNext
| Prop | Type | Default | Description |
|---|---|---|---|
nextLabel | string | Next | The label on every step but the last. |
finishLabel | string | Finish | The label on the last step. |
children | Snippet<[{ isLast, pending }]> | - | Replaces the label. Receives where the stepper is. |
Both accept the shadcn button's props as well.