Skip to content
LarawellUi

<x-widget.stepper>

Stepper

Step indicator for multi-step flows like checkout and onboarding: bars, dots, chevrons, or labelled steps (across or stacked with descriptions) that can link back, be locked, optional or need attention. Works inside Livewire components: it follows the current step through renders.

php artisan larawell:add stepper
Also adds
Icon

Usage

Livewire

A wizard in one Livewire component: pass the current step from a property and the stepper follows it on every render, the dots and bars too. A step's href is a link, so in a single-page wizard leave it out and give the step buttons of your own their wire:click.

Blade
<div class="space-y-6">
    <x-widget.stepper.labelled label="Checkout" :current="$step" :steps="['Cart', 'Address', 'Payment', 'Review']" />

    {{-- the current step's fields --}}

    <div class="flex justify-between">
        <x-widget.button variant="neutral" wire:click="back" :disabled="$step === 1">Back</x-widget.button>
        <x-widget.button wire:click="next">Continue</x-widget.button>
    </div>
</div>

Examples

Bars

One bar per step. Pass a count with :total, or the step names with :steps to show where the user is.

Show code
Blade
<div class="flex w-full max-w-md flex-col gap-6">
    <x-widget.stepper :total="4" :current="2" label="Verification" />
    <x-widget.stepper :steps="['Cart', 'Shipping', 'Payment', 'Review']" :current="2" label="Checkout" show-label show-steps />
    <x-widget.stepper :total="5" :current="4" label="Profile setup" show-label />
</div>

Labelled

Steps are labels, or arrays with a label plus href (links back to done steps) or locked.

  1. Account, completed
  2. Verify identity, completed
  3. Bank details, current step
  4. Go live, locked
Show code
Blade
<x-widget.stepper.labelled label="Onboarding" :current="3" :steps="[
    ['label' => 'Account', 'href' => '#account'],
    ['label' => 'Verify identity', 'href' => '#verify'],
    'Bank details',
    ['label' => 'Go live', 'locked' => true],
]" />

States

A step can need attention (error, and an href lets the user go back and fix it), be optional, or be locked until earlier steps are done. On phones only the current step's name shows, so longer flows still fit.

  1. Account, completed
  2. Verify identity, needs attention
  3. Bank details, current step
  4. Invite team, not started Optional
  5. Go live, locked
Show code
Blade
<x-widget.stepper.labelled label="Onboarding" :current="3" :steps="[
    ['label' => 'Account', 'href' => '#account'],
    ['label' => 'Verify identity', 'error' => true, 'href' => '#verify'],
    'Bank details',
    ['label' => 'Invite team', 'optional' => true],
    ['label' => 'Go live', 'locked' => true],
]" />

Vertical

orientation="vertical" stacks the steps with a description under each: onboarding checklists, order tracking. It fits phones as it is.

  1. Order placed, completed We have your order and payment.
  2. Packed, completed Your items are boxed and labelled.
  3. Shipped, current step On its way with the courier. Tracking arrives by email.
  4. Delivered, not started Usually 2 to 4 working days after shipping.
Show code
Blade
<x-widget.stepper.labelled class="max-w-sm" orientation="vertical" label="Order status" :current="3" :steps="[
    ['label' => 'Order placed', 'description' => 'We have your order and payment.', 'href' => '#order-placed'],
    ['label' => 'Packed', 'description' => 'Your items are boxed and labelled.'],
    ['label' => 'Shipped', 'description' => 'On its way with the courier. Tracking arrives by email.'],
    ['label' => 'Delivered', 'description' => 'Usually 2 to 4 working days after shipping.'],
]" />

Dots

Small dots for short wizards and carousels; the current step stretches into a pill.

Show code
Blade
<div class="flex flex-col items-center gap-6">
    <x-widget.stepper.dots :total="4" :current="2" label="Welcome tour" />
    <x-widget.stepper.dots :total="6" :current="5" label="Photo" />
</div>

Chevrons

Arrow-shaped segments in a row, like a checkout breadcrumb. Completed steps can link back; on phones the other steps shrink to their number.

  1. Cart , completed
  2. Shipping , completed
  3. Payment , current step
  4. Review , not started
Show code
Blade
<x-widget.stepper.chevrons label="Checkout" :current="3" :steps="[
    ['label' => 'Cart', 'href' => '#cart'],
    ['label' => 'Shipping', 'href' => '#shipping'],
    'Payment',
    'Review',
]" />

Props

Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

<x-widget.stepper>

Prop Default Description
total null A count of steps, or their names with :steps (then total can be left out).
steps []
current 1
label null
show-label false A line above the bars: the current step's name (or the label) and "Step 2 of 4".
show-steps false Step names under the bars from the sm breakpoint up; on phones they'd crowd each other.

<x-widget.stepper.chevrons>

Prop Default Description
steps []
current 1
label 'Progress'

<x-widget.stepper.dots>

Prop Default Description
total Required
current 1
label null

<x-widget.stepper.labelled>

Prop Default Description
steps []
current 1
label 'Progress'
orientation 'horizontal' horizontal: a row that shows only the current step's name on phones. vertical: stacked, with descriptions.

Source

What larawell:add stepper writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds , and the theme and base CSS.

resources/views/components/widget/stepper/chevrons.blade.php Show
chevrons.blade.php
@props([
    'steps' => [],
    'current' => 1,
    'label' => 'Progress',
])

@php
    // Each step is a label, or ['label' => …, 'href' => …]; href makes a completed step a link back to it.
    $steps = array_values(array_map(
        static fn (string|array $step): array => is_string($step) ? ['label' => $step] : $step,
        $steps,
    ));
    $current = max(1, min(max(1, count($steps)), (int) $current));

    // Arrow-shaped segments: a notch cut into the start, a point on the end. The first has no notch, the last
    // no point. Mirrored in RTL (and the text flipped back) so the arrows point the way you read.
    $shape = static fn (bool $first, bool $last): string => match (true) {
        $first && $last => '',
        $first => '[clip-path:polygon(0_0,calc(100%-12px)_0,100%_50%,calc(100%-12px)_100%,0_100%)]',
        $last => '[clip-path:polygon(0_0,100%_0,100%_100%,0_100%,12px_50%)]',
        default => '[clip-path:polygon(0_0,calc(100%-12px)_0,100%_50%,calc(100%-12px)_100%,0_100%,12px_50%)]',
    };
    // Solid tints (mixed with the surface, not see-through) so the overlapping points stay clean on any background.
    $look = ['done' => 'bg-[color-mix(in_oklab,var(--color-primary)_15%,var(--color-surface))] text-primary', 'current' => 'bg-primary text-on-primary', 'upcoming' => 'bg-field text-foreground/60'];
    $spoken = ['done' => 'completed', 'current' => 'current step', 'upcoming' => 'not started'];
@endphp

<ol aria-label="{{ $label }}" {{ $attributes->class(['flex w-full']) }}>
    @foreach ($steps as $index => $step)
        @php
            $number = $index + 1;
            $state = match (true) {
                $number < $current => 'done',
                $number === $current => 'current',
                default => 'upcoming',
            };
            $link = $state === 'done' && ! empty($step['href']) ? $step['href'] : null;
        @endphp

        {{-- Segments overlap by the point's width so each point sits in the next one's notch. --}}
        <li @if ($state === 'current') aria-current="step" @endif @class(['min-w-0', 'flex-[2]' => $state === 'current', 'flex-1' => $state !== 'current', '-ms-2.5' => ! $loop->first])>
            <{{ $link ? 'a' : 'div' }}
                @if ($link) href="{{ $link }}" @endif
                @class([
                    $look[$state],
                    $shape($loop->first, $loop->last),
                    'flex h-10 items-center justify-center px-5 text-sm font-medium outline-none rtl:-scale-x-100',
                    'rounded-s-lg' => $loop->first,
                    'rounded-e-lg' => $loop->last,
                    'hover:bg-[color-mix(in_oklab,var(--color-primary)_25%,var(--color-surface))] focus-visible:underline' => $link,
                ])
            >
                <span class="truncate rtl:-scale-x-100">
                    {{-- On phones the others shrink to their number; the current step keeps its name. --}}
                    @if ($state === 'current')
                        {{ $step['label'] }}
                    @else
                        <span class="max-sm:sr-only">{{ $step['label'] }}</span><span class="sm:hidden" aria-hidden="true">{{ $number }}</span>
                    @endif
                    <span class="sr-only">, {{ $spoken[$state] }}</span>
                </span>
            </{{ $link ? 'a' : 'div' }}>
        </li>
    @endforeach
</ol>
resources/views/components/widget/stepper/dots.blade.php Show
dots.blade.php
@props([
    'total',
    'current' => 1,
    'label' => null,
])

@php
    $total = max(1, (int) $total);
    $current = max(1, min($total, (int) $current));
    $text = ($label ? $label.': ' : '').'Step '.$current.' of '.$total;
@endphp

{{-- Small dots for short wizards and carousels; the current one stretches into a pill. Decorative, like the
     bars: the progressbar role carries "Step 2 of 4" for screen readers. --}}
<div
    role="progressbar"
    aria-valuemin="1"
    aria-valuemax="{{ $total }}"
    aria-valuenow="{{ $current }}"
    aria-valuetext="{{ $text }}"
    aria-label="{{ $label ?? 'Progress' }}"
    {{ $attributes->class(['flex items-center justify-center gap-1.5']) }}
>
    @for ($step = 1; $step <= $total; $step++)
        <span aria-hidden="true" @class([
            'h-2 rounded-full transition-all duration-300 motion-reduce:transition-none',
            'bg-primary w-6' => $step === $current,
            'bg-primary/40 w-2' => $step < $current,
            'bg-muted/75 w-2' => $step > $current,
        ])></span>
    @endfor
</div>
resources/views/components/widget/stepper/index.blade.php Show
index.blade.php
@props([
    // A count of steps, or their names with :steps (then total can be left out).
    'total' => null,
    'steps' => [],
    'current' => 1,
    'label' => null,
    // A line above the bars: the current step's name (or the label) and "Step 2 of 4".
    'showLabel' => false,
    // Step names under the bars from the sm breakpoint up; on phones they'd crowd each other.
    'showSteps' => false,
])

@php
    $steps = array_values($steps);
    $total = max(1, (int) ($total ?? count($steps)));
    $current = max(1, min($total, (int) $current));
    $name = $steps[$current - 1] ?? null;
    $text = ($label ? $label.': ' : '').'Step '.$current.' of '.$total.($name !== null ? ', '.$name : '');
@endphp

<div {{ $attributes->class(['w-full']) }}>
    @if ($showLabel)
        <div class="text-foreground mb-2 flex items-baseline justify-between gap-2 text-xs" aria-hidden="true">
            <span class="font-medium">{{ $name ?? $label }}</span>
            <span class="text-foreground/60 tabular-nums">Step {{ $current }} of {{ $total }}</span>
        </div>
    @endif

    {{-- The segments are decorative; the progressbar role carries the "Step 2 of 4" for screen readers. --}}
    <div
        role="progressbar"
        aria-valuemin="1"
        aria-valuemax="{{ $total }}"
        aria-valuenow="{{ $current }}"
        aria-valuetext="{{ $text }}"
        aria-label="{{ $label ?? 'Progress' }}"
        class="flex w-full items-center gap-2"
    >
        @for ($step = 1; $step <= $total; $step++)
            <span
                aria-hidden="true"
                @class([
                    'h-2 min-w-0 flex-1 rounded-full transition-colors duration-300 motion-reduce:transition-none',
                    'bg-primary' => $step <= $current,
                    // With names on show, a ring marks "you are here".
                    'ring-primary/25 ring-2' => $step === $current && ($showLabel || $showSteps),
                    // Upcoming segments at 3:1 against the page, so the bar's length reads before any label does.
                    'bg-muted/75' => $step > $current,
                ])
            ></span>
        @endfor
    </div>

    @if ($showSteps && $steps !== [])
        <ol class="text-foreground/60 mt-2 hidden gap-2 text-xs sm:flex" aria-hidden="true">
            @foreach ($steps as $i => $step)
                <li @class(['min-w-0 flex-1 truncate', 'text-foreground font-medium' => $i + 1 === $current])>{{ $step }}</li>
            @endforeach
        </ol>
    @endif
</div>
resources/views/components/widget/stepper/labelled.blade.php Show
labelled.blade.php
@props([
    'steps' => [],
    'current' => 1,
    'label' => 'Progress',
    // horizontal: a row that shows only the current step's name on phones. vertical: stacked, with descriptions.
    'orientation' => 'horizontal',
])

@php
    // Each step is a label, or an array: ['label' => …, 'description' => …, 'href' => …, 'locked' => bool,
    // 'error' => bool, 'optional' => bool]. `locked` marks a step that can't be reached yet (lock icon, dimmed);
    // `href` makes a completed step a link back to it; `error` flags a step that needs attention.
    $steps = array_values(array_map(
        static fn (string|array $step): array => is_string($step) ? ['label' => $step] : $step,
        $steps,
    ));
    $current = max(1, min(max(1, count($steps)), (int) $current));
    $vertical = $orientation === 'vertical';

    $stateOf = static fn (int $number, array $step): string => match (true) {
        ! empty($step['error']) => 'error',
        $number < $current => 'done',
        $number === $current => 'current',
        ! empty($step['locked']) => 'locked',
        default => 'upcoming',
    };
    // Outer ring, inner disc, and label, per state. The white gap between ring and disc is the padding.
    $ring = ['done' => 'border-foreground', 'current' => 'border-foreground', 'upcoming' => 'border-muted', 'locked' => 'border-line-strong', 'error' => 'border-error'];
    $disc = ['done' => 'bg-primary text-on-primary', 'current' => 'bg-foreground text-surface', 'upcoming' => 'bg-muted text-surface', 'locked' => 'bg-line-strong text-surface', 'error' => 'bg-error text-white'];
    $text = ['done' => 'text-foreground', 'current' => 'text-foreground', 'upcoming' => 'text-foreground', 'locked' => 'text-muted', 'error' => 'text-error'];
    $spoken = ['done' => 'completed', 'current' => 'current step', 'upcoming' => 'not started', 'locked' => 'locked', 'error' => 'needs attention'];
@endphp

<div {{ $attributes->class(['w-full']) }}>
    <ol aria-label="{{ $label }}" @class(['flex w-full', 'flex-col' => $vertical])>
        @foreach ($steps as $index => $step)
            @php
                $number = $index + 1;
                $state = $stateOf($number, $step);
                // A step that needs attention can be linked back to as well, so it can be fixed.
                $link = in_array($state, ['done', 'error'], true) && $number !== $current && ! empty($step['href']) ? $step['href'] : null;
                // The line leads up to the current step: dark after steps that are behind it.
                $lineDone = $number < $current;
            @endphp

            <li @if ($number === $current) aria-current="step" @endif @class([
                'relative flex min-w-0',
                'flex-1 flex-col items-center' => ! $vertical,
                'gap-3 pb-6 last:pb-0' => $vertical,
            ])>
                @unless ($loop->last)
                    {{-- Connector to the next step, from this circle's edge to the next one's (circles are 28px wide). --}}
                    <span aria-hidden="true" @class([
                        'absolute transition-colors duration-300',
                        'top-[13px] right-[calc(-50%+14px)] left-[calc(50%+14px)] h-0.5 rtl:right-[calc(50%+14px)] rtl:left-[calc(-50%+14px)]' => ! $vertical,
                        'start-[13px] top-8 bottom-1 w-0.5' => $vertical,
                        'bg-foreground' => $lineDone,
                        'bg-line-strong' => ! $lineDone,
                    ])></span>
                @endunless

                <{{ $link ? 'a' : 'div' }} @if ($link) href="{{ $link }}" @endif @class([
                    'flex max-w-full rounded-md outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
                    'flex-col items-center gap-2' => ! $vertical,
                    'items-start gap-3' => $vertical,
                    'group/step cursor-pointer' => $link,
                ])>
                    <span aria-hidden="true" class="{{ $ring[$state] }} bg-surface relative grid size-7 shrink-0 place-items-center rounded-full border-2 p-0.5 transition-colors duration-300">
                        <span class="{{ $disc[$state] }} grid size-full place-items-center rounded-full text-xs font-bold transition-colors duration-300 group-hover/step:opacity-80">
                            @if ($state === 'done')
                                <x-widget.icon name="check" class="size-3.5 stroke-[3]" />
                            @elseif ($state === 'error')
                                <x-widget.icon name="circle-alert" class="size-3.5" />
                            @elseif ($state === 'locked')
                                <x-widget.icon name="lock" class="size-3" />
                            @else
                                {{ $number }}
                            @endif
                        </span>
                    </span>

                    {{-- On phones a row only has room for the circles; the names stay for screen readers, and the line below names the current step. --}}
                    <span @class([
                        'flex min-w-0 flex-col',
                        'items-center px-1 text-center' => ! $vertical,
                        'max-sm:sr-only' => ! $vertical,
                        'pt-1' => $vertical,
                    ])>
                        <span class="{{ $text[$state] }} text-sm font-medium break-words">
                            {{ $step['label'] }}<span class="sr-only">, {{ $spoken[$state] }}</span>
                        </span>
                        @if (! empty($step['optional']))
                            <span class="text-muted text-xs">Optional</span>
                        @endif
                        @if ($vertical && ! empty($step['description']))
                            <span class="text-foreground/60 mt-0.5 text-sm">{{ $step['description'] }}</span>
                        @endif
                    </span>
                </{{ $link ? 'a' : 'div' }}>
            </li>
        @endforeach
    </ol>

    @unless ($vertical)
        {{-- Phones: where you are, in words, since the other names are hidden. Screen readers already have the list. --}}
        <p class="mt-3 flex flex-col items-center gap-0.5 text-center sm:hidden" aria-hidden="true">
            <span class="text-foreground text-sm font-medium">{{ $steps[$current - 1]['label'] ?? '' }}</span>
            <span class="text-foreground/60 text-xs tabular-nums">Step {{ $current }} of {{ count($steps) }}</span>
        </p>
    @endunless
</div>