Stepper
A long form cut into steps: a numbered index with state, one panel per step, and controls that move between them. The server sends an ordered list of links and the panels one under the other, so the whole form is readable and fillable without a script. On init only the current panel stays visible, the index marks what is done, current and still to come, and data-iv-step buttons refuse to advance while the fields of the panel are invalid.
<form class="iv-form" data-iv-component="form" novalidate aria-describedby="step-note">
<p class="iv-u-text-sm iv-u-text-muted" id="step-note">Demo checkout: nothing is submitted, no order is placed and no payment is taken. The fields are validated in the browser so the steps can refuse to advance, and the last button only announces that the flow is complete.</p>
<div class="iv-stepper" data-iv-component="stepper">
<ol class="iv-stepper__list" aria-label="Checkout">
<li class="iv-stepper__step" data-iv-state="current">
<a class="iv-stepper__trigger" href="#step-account" aria-current="step">
<span class="iv-stepper__marker" aria-hidden="true">1</span>
<span class="iv-stepper__label">Account</span>
<span class="iv-stepper__hint">Email and team</span>
</a>
</li>
<li class="iv-stepper__step">
<a class="iv-stepper__trigger" href="#step-payment">
<span class="iv-stepper__marker" aria-hidden="true">2</span>
<span class="iv-stepper__label">Payment</span>
<span class="iv-stepper__hint">Card holder and country</span>
</a>
</li>
<li class="iv-stepper__step">
<a class="iv-stepper__trigger" href="#step-review">
<span class="iv-stepper__marker" aria-hidden="true">3</span>
<span class="iv-stepper__label">Review</span>
<span class="iv-stepper__hint">Confirm the demo</span>
</a>
</li>
</ol>
<div class="iv-stepper__progress" aria-hidden="true"></div>
<div class="iv-stepper__panels">
<section class="iv-stepper__panel" id="step-account" aria-labelledby="step-account-title" tabindex="-1">
<h3 class="iv-u-mt-0" id="step-account-title">Account</h3>
<div class="iv-field">
<label class="iv-label" for="step-email">Work email <span class="iv-label__required" aria-hidden="true">*</span></label>
<input class="iv-input" id="step-email" name="email" type="email" required autocomplete="email">
</div>
<div class="iv-field">
<label class="iv-label" for="step-team">Team name <span class="iv-label__required" aria-hidden="true">*</span></label>
<input class="iv-input" id="step-team" name="team" type="text" required minlength="2" autocomplete="organization">
</div>
<div class="iv-stepper__controls" hidden>
<button class="iv-button iv-button--secondary" type="button" data-iv-step="prev" disabled>Back</button>
<button class="iv-button iv-button--primary" type="button" data-iv-step="next">Continue</button>
</div>
</section>
<section class="iv-stepper__panel" id="step-payment" aria-labelledby="step-payment-title" tabindex="-1">
<h3 class="iv-u-mt-0" id="step-payment-title">Payment</h3>
<div class="iv-field">
<label class="iv-label" for="step-holder">Card holder <span class="iv-label__required" aria-hidden="true">*</span></label>
<input class="iv-input" id="step-holder" name="holder" type="text" required autocomplete="cc-name">
</div>
<div class="iv-field">
<label class="iv-label" for="step-country">Billing country <span class="iv-label__required" aria-hidden="true">*</span></label>
<select class="iv-select" id="step-country" name="country" required>
<option value="">Select a country</option>
<option value="es">Spain</option>
<option value="pt">Portugal</option>
<option value="fr">France</option>
</select>
</div>
<p class="iv-u-text-sm iv-u-text-muted iv-u-m-0">No card number is asked for: this demo has nothing to charge.</p>
<div class="iv-stepper__controls" hidden>
<button class="iv-button iv-button--secondary" type="button" data-iv-step="prev">Back</button>
<button class="iv-button iv-button--primary" type="button" data-iv-step="next">Continue</button>
</div>
</section>
<section class="iv-stepper__panel" id="step-review" aria-labelledby="step-review-title" tabindex="-1">
<h3 class="iv-u-mt-0" id="step-review-title">Review</h3>
<p>Nothing on this page leaves the browser. Confirming only emits the completion event of the component, which a real site would use to submit its own form.</p>
<div class="iv-field">
<label class="iv-check"><input id="step-confirm" name="confirm" type="checkbox" required> I understand this demo sends nothing <span class="iv-label__required" aria-hidden="true">*</span></label>
</div>
<div class="iv-stepper__controls" hidden>
<button class="iv-button iv-button--secondary" type="button" data-iv-step="prev">Back</button>
<button class="iv-button iv-button--primary" type="button" data-iv-step="next">Finish the demo</button>
</div>
</section>
</div>
</div>
</form>// data-iv-component="form"
// data-iv-component="stepper"
{
state: "current",
step: "prev",
}Vertical index
iv-stepper--vertical moves the index into a column beside the panels from the md breakpoint up, with the connector running down instead of across. Below that breakpoint it is the same stacked layout as the default.
<div class="iv-stepper iv-stepper--vertical" data-iv-component="stepper" data-iv-validate="false">
<ol class="iv-stepper__list" aria-label="Release">
<li class="iv-stepper__step">
<a class="iv-stepper__trigger" href="#rel-draft">
<span class="iv-stepper__marker" aria-hidden="true">1</span>
<span class="iv-stepper__label">Draft</span>
<span class="iv-stepper__hint">Write the notes</span>
</a>
</li>
<li class="iv-stepper__step">
<a class="iv-stepper__trigger" href="#rel-review">
<span class="iv-stepper__marker" aria-hidden="true">2</span>
<span class="iv-stepper__label">Review</span>
<span class="iv-stepper__hint">Two pairs of eyes</span>
</a>
</li>
<li class="iv-stepper__step">
<a class="iv-stepper__trigger" href="#rel-build">
<span class="iv-stepper__marker" aria-hidden="true">3</span>
<span class="iv-stepper__label">Build</span>
<span class="iv-stepper__hint">Sizes and tests</span>
</a>
</li>
<li class="iv-stepper__step">
<a class="iv-stepper__trigger" href="#rel-ship">
<span class="iv-stepper__marker" aria-hidden="true">4</span>
<span class="iv-stepper__label">Ship</span>
<span class="iv-stepper__hint">Only with a signature</span>
</a>
</li>
</ol>
<div class="iv-stepper__panels">
<section class="iv-stepper__panel" id="rel-draft" aria-labelledby="rel-draft-title" tabindex="-1">
<h3 class="iv-u-mt-0" id="rel-draft-title">Draft</h3>
<p>Sample flow, invented for this fixture: no release is prepared or published from this page. The vertical variant keeps the index in a column beside the panels from the <code>md</code> breakpoint upwards.</p>
<div class="iv-stepper__controls" hidden>
<button class="iv-button iv-button--secondary" type="button" data-iv-step="prev" disabled>Back</button>
<button class="iv-button iv-button--primary" type="button" data-iv-step="next">Continue</button>
</div>
</section>
<section class="iv-stepper__panel" id="rel-review" aria-labelledby="rel-review-title" tabindex="-1">
<h3 class="iv-u-mt-0" id="rel-review-title">Review</h3>
<p>The connector between markers fills up to the step you are on, and every step already done shows a check instead of its number.</p>
<div class="iv-stepper__controls" hidden>
<button class="iv-button iv-button--secondary" type="button" data-iv-step="prev">Back</button>
<button class="iv-button iv-button--primary" type="button" data-iv-step="next">Continue</button>
</div>
</section>
<section class="iv-stepper__panel" id="rel-build" aria-labelledby="rel-build-title" tabindex="-1">
<h3 class="iv-u-mt-0" id="rel-build-title">Build</h3>
<p>There is nothing to validate in this fixture, so the panels advance freely: the steps carry text, not fields.</p>
<div class="iv-stepper__controls" hidden>
<button class="iv-button iv-button--secondary" type="button" data-iv-step="prev">Back</button>
<button class="iv-button iv-button--primary" type="button" data-iv-step="next">Continue</button>
</div>
</section>
<section class="iv-stepper__panel" id="rel-ship" aria-labelledby="rel-ship-title" tabindex="-1">
<h3 class="iv-u-mt-0" id="rel-ship-title">Ship</h3>
<p>The last step does not publish anything: it announces that the flow is complete and leaves the decision to whoever is reading.</p>
<div class="iv-stepper__controls" hidden>
<button class="iv-button iv-button--secondary" type="button" data-iv-step="prev">Back</button>
<button class="iv-button iv-button--primary" type="button" data-iv-step="next">Finish the demo</button>
</div>
</section>
</div>
</div>// data-iv-component="stepper"
{
validate: false,
step: "prev",
}Compact
iv-stepper--compact draws the markers and the label of the current step only; the other labels stay in the page for screen readers, so no step loses its name. This one also runs with data-iv-linear="false", so every step is reachable from the index at any time.
<div class="iv-stepper iv-stepper--compact" data-iv-component="stepper" data-iv-linear="false" data-iv-validate="false">
<ol class="iv-stepper__list" aria-label="Onboarding">
<li class="iv-stepper__step">
<a class="iv-stepper__trigger" href="#onb-profile">
<span class="iv-stepper__marker" aria-hidden="true">1</span>
<span class="iv-stepper__label">Profile</span>
</a>
</li>
<li class="iv-stepper__step">
<a class="iv-stepper__trigger" href="#onb-workspace">
<span class="iv-stepper__marker" aria-hidden="true">2</span>
<span class="iv-stepper__label">Workspace</span>
</a>
</li>
<li class="iv-stepper__step">
<a class="iv-stepper__trigger" href="#onb-invite">
<span class="iv-stepper__marker" aria-hidden="true">3</span>
<span class="iv-stepper__label">Invites</span>
</a>
</li>
<li class="iv-stepper__step">
<a class="iv-stepper__trigger" href="#onb-done">
<span class="iv-stepper__marker" aria-hidden="true">4</span>
<span class="iv-stepper__label">Done</span>
</a>
</li>
</ol>
<div class="iv-stepper__progress" aria-hidden="true"></div>
<div class="iv-stepper__panels">
<section class="iv-stepper__panel" id="onb-profile" aria-labelledby="onb-profile-title" tabindex="-1">
<h3 class="iv-u-mt-0" id="onb-profile-title">Profile</h3>
<p>Sample onboarding, invented for this fixture: no account exists and nothing is saved. In the compact variant only the markers and the label of the current step are drawn; the other labels stay in the page for screen readers, so every step keeps its name.</p>
<div class="iv-stepper__controls" hidden>
<button class="iv-button iv-button--secondary" type="button" data-iv-step="prev" disabled>Back</button>
<button class="iv-button iv-button--primary" type="button" data-iv-step="next">Continue</button>
</div>
</section>
<section class="iv-stepper__panel" id="onb-workspace" aria-labelledby="onb-workspace-title" tabindex="-1">
<h3 class="iv-u-mt-0" id="onb-workspace-title">Workspace</h3>
<p>This stepper is not linear, so every step can be reached from the index at any time and the arrow keys walk the whole row.</p>
<div class="iv-stepper__controls" hidden>
<button class="iv-button iv-button--secondary" type="button" data-iv-step="prev">Back</button>
<button class="iv-button iv-button--primary" type="button" data-iv-step="next">Continue</button>
</div>
</section>
<section class="iv-stepper__panel" id="onb-invite" aria-labelledby="onb-invite-title" tabindex="-1">
<h3 class="iv-u-mt-0" id="onb-invite-title">Invites</h3>
<p>No invitation is sent from here: there is no address book and no mail behind this page.</p>
<div class="iv-stepper__controls" hidden>
<button class="iv-button iv-button--secondary" type="button" data-iv-step="prev">Back</button>
<button class="iv-button iv-button--primary" type="button" data-iv-step="next">Continue</button>
</div>
</section>
<section class="iv-stepper__panel" id="onb-done" aria-labelledby="onb-done-title" tabindex="-1">
<h3 class="iv-u-mt-0" id="onb-done-title">Done</h3>
<p>The progress bar above reads the same custom property the connector uses, so both tell the same story.</p>
<div class="iv-stepper__controls" hidden>
<button class="iv-button iv-button--secondary" type="button" data-iv-step="prev">Back</button>
<button class="iv-button iv-button--primary" type="button" data-iv-step="next">Finish the demo</button>
</div>
</section>
</div>
</div>// data-iv-component="stepper"
{
linear: false,
validate: false,
step: "prev",
}Options
| Option | Attribute | Default | Effect |
|---|---|---|---|
linear | data-iv-linear | true | Only steps already visited and the current one are reachable from the index; the rest carry aria-disabled="true". Going back and jumping forward again does not revalidate. |
validate | data-iv-validate | true | Check the controls of the current panel before advancing. |
hash | data-iv-hash | false | Keep location.hash in sync with the current panel and answer hashchange. |
focus | data-iv-focus | panel | Where focus lands after a change: the new panel, or none. |
statusText | data-iv-status-text | Step {index} of {total}: {label} | Sentence announced in the polite live region after every change. |
Methods and events
go(index, reason), next(), prev(), reset(), setState(index, state), destroy(); read index (0-based), steps, isFirst and isLast. setState is how a server answer marks a step error or done from your own code.
| Event | When | detail |
|---|---|---|
iv:change → iv:changed | before and after moving; the first is cancelable | index, previousIndex, reason: trigger, next, prev, api or hash |
iv:complete | next on the last step, once its validation has passed | index |
The component never submits anything. iv:complete is where your code sends the form, and cancelling iv:change is where it refuses a move for a reason of its own.
Validation
With validate on, next checks the current panel first. If the panel sits inside a <form> that has a Form instance, every control in the panel goes through validateField and the messages appear where that component puts them; otherwise the browser's own reportValidity() stops on the first invalid control. Going back never validates. Asynchronous checks, conditional steps and draft saving are out of scope: do them in your own handler and call go() or setState() with the answer.
Keyboard and assistive technology
The index is an <ol> of links with a label, and the current step carries aria-current="step". Left and Right, Home and End move focus between reachable triggers; Enter or Space activates one. The markers are aria-hidden decoration — the number they draw is already the list order — and each panel is a section with a heading and tabindex="-1" so focus can land on it. State is announced in a visually hidden polite region, and a step in error adds a hidden "has errors" to its label, so colour is never the only carrier.
Without JavaScript
Every panel is served visible, one under the other, and the index is a list of anchors that jump to each section. The next and back buttons are served inside a hidden container, because a button that cannot advance is worse than no button; init reveals them. Nothing is hidden and no field is unreachable: the form is one long form, which is exactly what it was before it was cut into steps.
Tokens and locals
| Local | Default | What it moves |
|---|---|---|
--iv-stepper-marker | 2rem | Diameter of the numbered circle. |
--iv-stepper-line | var(--iv-color-border) | Colour of the connector between markers. |
--iv-stepper-fill | var(--iv-color-primary) | Colour of the part of the connector already walked. |
--iv-stepper-track | 2px | Thickness of the connector and of the progress bar. |
--iv-stepper-gap | var(--iv-space-6) | Space between the index and the panels. |
--iv-stepper-step-gap | var(--iv-space-5) | Space between steps in the index. |
The component writes --iv-stepper-progress (0 to 1) on the root, which is what the optional iv-stepper__progress bar scales. Each step carries data-iv-state with done, current, upcoming or error: a done marker shows a check instead of its number, the current one takes the shared focus halo, and an error marker turns to the danger colour. Under reduced motion the panels appear without travelling.
Common mistakes
- Steps with no heading. Each panel needs its own heading: it is the name of the step for anyone who cannot see the index.
- Serving the panels hidden, or the controls visible. The first loses the form without a script; the second offers buttons that do nothing.
- More than five or six steps. Past that, an index is a table of contents and the form wants splitting into pages.
- Expecting the component to submit. It announces completion; sending the data stays yours.