Wizard
<x-wirekit::stepper> draws where you are and does not know it. It is an <ol> driven by a
current prop, with no state and no controls — correct for an indicator, and it left every
application to rebuild the same three things around it: which step is showing, whether you
may leave it, and how the change reaches somebody who cannot see it.
<x-wirekit::wizard> is that container. It uses the stepper rather than replacing it.
-
Details
-
Payment
-
Review
Your details
Payment
Review
Everything looks right.
Gating a step
Pass complete and the flow will not leave that step until it is true. Drive it from
whatever already knows — a Livewire property, a validated form object:
-
Terms
-
Done
Accept the terms
Next is held back while this step is incomplete.
You would not get here.
{{-- 1. `complete` is read from the DOM each time Next is pressed, not cached — so a
server-side re-render lands without anything having to notify Alpine. --}}
<x-wirekit::wizard.step :index="1" :complete="$form->detailsAreValid">
…
</x-wirekit::wizard.step>
Going back is never gated. The condition guards leaving a step forward with it unfinished; refusing to return would strand somebody on the step they cannot complete.
The step change is announced
A step change replaces a panel with no page load behind it: focus has not moved, no landmark changed, and the new panel simply exists. Nothing tells a screen reader anything happened.
So the wizard carries a polite live region and writes a sentence into it — "Step 2 of 3: Payment" — built from a translatable template rather than assembled from fragments. It is present from the first render and empty until a step changes, so nothing is read on load.
A step is hidden, not destroyed
The panel uses x-show plus hidden, and the pair is deliberate. x-if would destroy
the panel, so every field on a step you stepped back from would silently lose what was typed
in it, along with any wire:model binding — which is the difference between a multi-step
form and a tab set. Do not swap it.
hidden alongside x-show keeps the inactive panel out of the accessibility tree and out
of the tab order. Without it a four-step flow is read as one long page.
Your own controls
Pass a controls slot to replace the default Back and Next — a submit button on the last
step, a Cancel, whatever the flow needs. next(), prev(), canAdvance, isFirst and
isLast are all available in that scope:
{{-- 2. The default controls disappear as soon as you supply your own. --}}
<x-wirekit::wizard :steps="$steps">
…
<x-slot:controls>
<x-wirekit::row justify="end" gap="sm">
<x-wirekit::button surface="ghost" x-on:click="prev()" x-bind:hidden="isFirst ? true : null">Back</x-wirekit::button>
<x-wirekit::button x-on:click="next()" x-bind:hidden="isLast ? true : null">Continue</x-wirekit::button>
<x-wirekit::button wire:click="submit" x-bind:hidden="isLast ? null : true">Place order</x-wirekit::button>
</x-wirekit::row>
</x-slot:controls>
</x-wirekit::wizard>
Props
<x-wirekit::wizard>
| Prop | Type | Default | Description |
|---|---|---|---|
steps |
array |
[] |
Step names, in order. They drive the indicator and the announcement. |
current |
int |
1 |
Which step shows first, 1-based. Clamped to the number of steps. |
indicator |
bool |
true |
Draw the stepper above the panel. Turn it off when the page already shows progress its own way — two indicators for one flow is worse than one. |
orientation |
string |
horizontal |
Passed through to the stepper. |
scope |
string|null |
null |
Scoped personalization key |
<x-wirekit::wizard.step>
| Prop | Type | Default | Description |
|---|---|---|---|
index |
int |
required | Position in the flow, 1-based. The container finds a step by this number rather than by DOM order, so a conditionally rendered step cannot shift the others. |
complete |
bool|null |
null |
Whether the flow may leave this step. Unset means yes. |
scope |
string|null |
null |
Scoped personalization key |
Sub-Components
| Component | Purpose |
|---|---|
wizard.step |
One panel of the flow, shown when its index is current |
Accessibility
- The step change is announced through a
role="status"/aria-live="polite"region that exists from the first render. - An inactive panel carries
hidden, so it is out of both the accessibility tree and the tab order. - The default controls are ordinary buttons — the keyboard model is the browser's, and there is nothing bespoke to get wrong.
- Next carries
aria-disabledrather thandisabledwhile a step is incomplete, so it stays focusable and the reason it will not advance can be announced. - The indicator stays presentational. It reflects the position and owns no navigation, which is the stepper's own documented position.
Keyboard Interaction
| Key | Action |
|---|---|
| Tab | Moves through the fields of the current step, then to the controls |
| Enter / Space | Activates the focused control |
There is no wizard-specific key model, and that is deliberate: the controls are buttons, so the browser's own behavior is the whole contract.
Pitfalls
- Number the steps. A
wizard.stepwithoutindexcan never be shown and never gates anything; it says so in the console whileapp.debugis on. - Do not make the indicator clickable. It shows where the reader is; it is not a nav. A jump-back in a flow with validation undermines exactly what the gating is for.
- One indicator per flow. If the page already shows progress, pass
indicator="false"rather than letting two of them disagree.