<!-- Basic stepper (linear mode, validates before advancing) -->
<div stepper>
<div step>
<h3>Personal Info</h3>
<input model="name" required />
<input model="email" type="email" required />
</div>
<div step>
<h3>Address</h3>
<input model="street" required />
<input model="city" required />
</div>
<div step>
<h3>Confirmation</h3>
<p>Name: <span bind="name"></span></p>
<p>Email: <span bind="email"></span></p>
</div>
</div>
<!-- Start on a specific step (0-based index) -->
<div stepper="1">
<div step>Step 1</div>
<div step>Step 2 (shown first)</div>
</div>
<!-- Free mode (any step clickable in the indicator) -->
<div stepper stepper-mode="free">
<div step step-label="Account">...</div>
<div step step-label="Profile">...</div>
<div step step-label="Review">...</div>
</div>
<!-- Hide indicator or navigation buttons -->
<div stepper stepper-indicator="false" stepper-nav="false">
<div step>Manual control only</div>
<div step>Use $stepper API</div>
</div>| Attribute | Type | Default | Description |
|---|---|---|---|
stepper |
number | 0 |
Initial step index (0-based) |
stepper-mode |
"linear" | "free" |
"linear" |
Navigation mode. Linear validates before advancing; free allows clicking any step |
stepper-indicator |
"true" | "false" |
"true" |
Show/hide the progress indicator |
stepper-nav |
"true" | "false" |
"true" |
Show/hide Previous/Next buttons |
aria-label |
string | "Stepper" |
Accessible label for the stepper container |
Each direct child with the step attribute defines one step panel.
<div stepper>
<!-- Basic step (indicator shows "Step 1") -->
<div step>
<h3>Details</h3>
<input model="details" required />
</div>
<!-- Custom label in the progress indicator -->
<div step step-label="Payment">
<h3>Payment Method</h3>
<input model="card" required />
</div>
<!-- Custom validation expression -->
<div step step-label="Review" step-validate="age >= 18">
<p>You must be 18+ to continue.</p>
</div>
</div>| Attribute | Type | Default | Description |
|---|---|---|---|
step |
— | — | Marks the element as a step panel |
step-label |
string | "Step N" |
Custom label shown in the progress indicator |
step-validate |
expression | — | Expression that must be truthy for the step to pass validation (linear mode). Checked in addition to required inputs |
Inside any stepper container, the $stepper object is available in expressions:
<div stepper>
<div step step-label="Info">
<h3>Step <span bind="$stepper.current + 1"></span> of <span bind="$stepper.total"></span></h3>
<input model="name" required />
</div>
<div step step-label="Done">
<p>All done!</p>
</div>
<!-- Custom navigation using the API -->
<button on:click="$stepper.prev()" disabled="$stepper.isFirst">Back</button>
<button on:click="$stepper.next()" hidden="$stepper.isLast">Continue</button>
<button on:click="submitForm()" show="$stepper.isLast">Submit</button>
</div>| Property | Type | Description |
|---|---|---|
$stepper.current |
number | Current step index (0-based) |
$stepper.total |
number | Total number of steps |
$stepper.isFirst |
boolean | true when on the first step |
$stepper.isLast |
boolean | true when on the last step |
| Method | Returns | Description |
|---|---|---|
$stepper.next() |
boolean | Advance to the next step. In linear mode, validates the current step first. Returns false if validation fails or already on the last step |
$stepper.prev() |
boolean | Go back to the previous step. Returns false if already on the first step |
$stepper.goTo(index) |
boolean | Jump to a specific step. In linear mode, validates all intermediate steps when moving forward. Returns false if out of bounds or validation fails |
| Event | $event.detail |
Description |
|---|---|---|
step-change |
{ current, total } |
Dispatched on the stepper element whenever the active step changes |
step-complete |
{ current, total } |
Dispatched when $stepper.next() is called on the last step (i.e., the stepper is "finished") |
<div stepper on:step-change="console.log('Step', $event.detail.current + 1)">
<div step>Step 1</div>
<div step>Step 2</div>
</div>
<!-- Listen for stepper completion -->
<div stepper on:step-complete="console.log('All', $event.detail.total, 'steps done')">
<div step>Step 1</div>
<div step>Step 2</div>
</div>In linear mode (the default), the stepper validates the current step before allowing forward navigation. Validation checks:
- All
[required]inputs within the step viacheckValidity()/reportValidity() - The
step-validateexpression (if present) must evaluate to a truthy value
<div stepper>
<!-- Step 1: required inputs must be filled -->
<div step step-label="Account">
<input model="username" required placeholder="Username" />
<input model="password" type="password" required placeholder="Password" />
</div>
<!-- Step 2: custom expression validation -->
<div step step-label="Terms" step-validate="acceptedTerms">
<label>
<input type="checkbox" model="acceptedTerms" />
I accept the terms and conditions
</label>
</div>
<!-- Step 3: no validation needed -->
<div step step-label="Confirm">
<p>Welcome, <span bind="username"></span>!</p>
</div>
</div>Going backward never requires validation. Using $stepper.goTo(index) forward in linear mode validates all intermediate steps sequentially.
In free mode, every step in the progress indicator becomes clickable, allowing users to jump to any step without validation.
<div stepper stepper-mode="free">
<div step step-label="General">
<h3>General Settings</h3>
<input model="siteName" placeholder="Site name" />
</div>
<div step step-label="Theme">
<h3>Theme Settings</h3>
<input model="primaryColor" type="color" />
</div>
<div step step-label="Advanced">
<h3>Advanced Settings</h3>
<input model="cacheTimeout" type="number" />
</div>
</div>No.JS Elements automatically injects these classes:
| Class | When applied |
|---|---|
.nojs-stepper |
On the stepper container |
.nojs-stepper-indicator |
On the generated progress indicator bar |
.nojs-stepper-indicator-item |
On each step button in the indicator |
.nojs-stepper-indicator-item[aria-selected="true"] |
On the active step indicator (bold number) |
.nojs-stepper-indicator-item[data-completed] |
On completed steps (shows checkmark instead of number) |
.nojs-stepper-indicator-item[data-clickable] |
On clickable steps in free mode |
.nojs-stepper-separator |
On the line between indicator items |
.nojs-step |
On each step panel |
.nojs-step[aria-hidden="true"] |
On inactive step panels (display: none) |
.nojs-stepper-nav |
On the navigation button container |
.nojs-stepper-prev |
On the Previous button |
.nojs-stepper-next |
On the Next button (text changes to "Finish" on the last step) |
No.JS Elements automatically adds:
role="group"andaria-labelon the stepper containerrole="tablist"on the progress indicatorrole="tab"witharia-selectedon each indicator itemrole="tabpanel"on each step panelaria-controls/aria-labelledbylinking tabs to panelsaria-hidden="true"andinerton inactive step panelstabindex="0"on the active indicator and step panels- Roving tabindex across indicator items
- Arrow Right / Arrow Left — move focus between indicator items (navigates in free mode)
- Home — move focus to the first indicator item
- End — move focus to the last indicator item
Requires NoJS Elements with the Validation module. See the migration guide if upgrading from Core-only validation.
When a step contains a <form validate> element, you can add stepper-validate to the step to block forward navigation until the form is valid. This integrates the stepper's linear mode with the validation module's $form.valid state.
<div stepper>
<!-- Step 1: gated by form validation -->
<div step step-label="Account" stepper-validate>
<form validate>
<input name="username" model="username" validate="required" placeholder="Username" />
<span if="$form.fields.username?.touched && $form.fields.username?.error"
bind="$form.fields.username.error" style="color: red"></span>
<input name="email" model="email" type="email" validate="required|email" placeholder="Email" />
<span if="$form.fields.email?.touched && $form.fields.email?.error"
bind="$form.fields.email.error" style="color: red"></span>
</form>
</div>
<!-- Step 2: gated by custom expression (existing feature) -->
<div step step-label="Terms" step-validate="acceptedTerms">
<label>
<input type="checkbox" model="acceptedTerms" />
I accept the terms and conditions
</label>
</div>
<!-- Step 3: no validation -->
<div step step-label="Confirm">
<p>Welcome, <span bind="username"></span>!</p>
</div>
</div>- When the user attempts to advance (via the Next button,
$stepper.next(), or$stepper.goTo()), the stepper checks the current step for thestepper-validateattribute - If present, it looks for a
<form validate>inside the step and reads$form.validfrom the form's context - If
$form.validisfalse, navigation is blocked, all fields in the form are touched (making errors visible), and astepper:validation-blockedevent is dispatched - If
$form.validistrue, navigation proceeds normally - Backward navigation is never blocked by the validation gate
| Attribute | Type | Description |
|---|---|---|
stepper-validate |
boolean attr | Place on a [step] element. Blocks forward navigation until the step's <form validate> is valid |
| Event | $event.detail |
Description |
|---|---|---|
stepper:validation-blocked |
{ step, form } |
Dispatched on the stepper container when forward navigation is blocked by an invalid form |
<div stepper
on:stepper:validation-blocked="console.log('Blocked on step', $event.detail.step)">
<div step stepper-validate>
<form validate>...</form>
</div>
<div step>Done</div>
</div>| Class | When applied |
|---|---|
.nojs-step-invalid |
Applied to the step panel when validation blocks navigation (fields are touched and errors become visible) |
stepper-validate (form-based gate) and step-validate (expression-based gate) can be used together on the same step. Both must pass for forward navigation to be allowed.
<div step step-label="Payment"
stepper-validate
step-validate="paymentMethod !== ''">
<form validate>
<select name="paymentMethod" model="paymentMethod" validate="required">
<option value="">Select payment method</option>
<option value="card">Credit Card</option>
<option value="paypal">PayPal</option>
</select>
<input name="cardNumber" model="cardNumber"
validate="required" validate-if="paymentMethod === 'card'" />
</form>
</div>Next: Skeleton →