Skip to content
LarawellUi

<x-widget.progress>

Progress

Progress bars, rings and stacked usage bars. Values in your own units, indeterminate and striped states, and progress.set() to update one from JavaScript. For step-by-step flows, see the stepper. Works inside Livewire components, wire:poll included.

php artisan larawell:add progress
Also adds
Nothing else. It stands alone.

Usage

In scripts

window.progress is available once the progress script has loaded. <x-widget.progress id="upload" label="Uploading" show-label />

JavaScript
progress.set('upload', 60);

// Value out of the bar's max, with your own wording (also what screen readers hear).
// <x-widget.progress id="files" :max="5" label="Files" show-label />
progress.set('files', 3, { text: '3 of 5 files' });

// e.g. from an upload's progress event
request.upload.addEventListener('progress', (event) => {
    progress.set('upload', (event.loaded / event.total) * 100);
});

Livewire

In a Livewire component, pass the value from a property and every render redraws the bar: after an action, or on wire:poll while a job runs. A bar you drive from JavaScript with progress.set() instead, such as from Livewire's upload progress events, goes in a wire:ignore, or the next render puts back the server's value.

Blade
<div wire:poll.1s="refreshExport" class="space-y-6">
    <x-widget.progress :value="$export->processed" :max="$export->total" label="Exporting" show-label :value-text="$export->processed.' of '.$export->total.' rows'" />
</div>

<div x-on:livewire-upload-progress="progress.set('upload', $event.detail.progress)" class="space-y-3">
    <x-widget.file-upload label="Video" wire:model="video" />
    <div wire:ignore>
        <x-widget.progress id="upload" label="Uploading" show-label />
    </div>
</div>

Examples

Linear

Uploading documents 35%
Verification 100%
Withdrawal failed 60%
Paused 40%
Show code
Blade
<div class="grid gap-5 sm:grid-cols-2">
    <x-widget.progress :value="35" label="Uploading documents" show-label />
    <x-widget.progress :value="100" label="Verification" show-label />
    <x-widget.progress :value="60" label="Withdrawal failed" show-label failed />
    <x-widget.progress :value="40" label="Paused" show-label disabled />
</div>

Values

:max makes progress "value out of max" in your own units, and value-text says it in words: shown instead of the percentage and read out by screen readers. label-position puts the label above, beside or inside the bar.

Uploading 3 of 5 files
Storage
1.2 GB of 5 GB
Raised $750 of $1,000
Show code
Blade
<div class="flex w-full flex-col gap-5">
    <x-widget.progress :value="3" :max="5" value-text="3 of 5 files" label="Uploading" show-label />
    <x-widget.progress :value="1.2" :max="5" value-text="1.2 GB of 5 GB" label="Storage" show-label label-position="beside" />
    <x-widget.progress :value="750" :max="1000" value-text="$750 of $1,000" label="Raised" show-label label-position="inside" />
</div>

Indeterminate

indeterminate slides while the amount isn't known yet; striped adds moving stripes to a bar that is. Both stop moving for people who prefer reduced motion.

Preparing your export
Processing payments 65%
Show code
Blade
<div class="grid w-full gap-5 sm:grid-cols-2">
    <x-widget.progress indeterminate label="Preparing your export" show-label />
    <x-widget.progress :value="65" striped label="Processing payments" show-label />
</div>

Live

progress.set() moves the bar and updates what screen readers hear in one call; the script beside this calls it as the import goes. An indeterminate bar turns into a normal one on the first set().

Uploading report.pdf
Importing
0 of 5 files
Show code
Blade
<div class="flex w-full flex-col gap-5">
    <x-widget.progress id="upload-progress" indeterminate label="Uploading report.pdf" show-label />
    <x-widget.progress id="file-progress" :max="5" value-text="0 of 5 files" label="Importing" show-label label-position="beside" />

    <div>
        <button
            type="button"
            class="bg-primary text-on-primary hover:bg-primary-hover focus-visible:ring-primary rounded-xl px-4 py-2.5 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
            data-start-import
        >Start</button>
    </div>
</div>
JavaScript, in your own JS file
// Stands in for a real import: five steps, one every 600ms.
document.addEventListener('click', (event) => {
    if (!event.target.closest('[data-start-import]')) {
        return;
    }
    let done = 0;
    const timer = setInterval(() => {
        done++;
        progress.set('upload-progress', done * 20);
        progress.set('file-progress', done, { text: `${done} of 5 files` });
        if (done === 5) {
            clearInterval(timer);
        }
    }, 600);
});

Circular

indeterminate spins for work with no known end.

Uploading
Verified
Failed
Loading
Large
Show code
Blade
<div class="flex flex-wrap items-end gap-8">
    <x-widget.progress.circular :value="35" label="Uploading" show-label />
    <x-widget.progress.circular :value="100" label="Verified" show-label />
    <x-widget.progress.circular :value="60" label="Failed" show-label failed />
    <x-widget.progress.circular indeterminate label="Loading" show-label />
    <x-widget.progress.circular :value="85" size="lg" label="Large" show-label />
</div>

Stacked

Parts of one whole in a single bar with a legend, e.g. storage or a budget. Colours follow the theme in order unless a segment names one; what's left over shows as free.

Storage 37.5 GB of 50 GB used
Show code
Blade
<x-widget.progress.stacked
    class="w-full"
    label="Storage"
    summary="37.5 GB of 50 GB used"
    :max="50"
    :segments="[
        ['label' => 'Photos', 'value' => 18.4, 'text' => '18.4 GB'],
        ['label' => 'Videos', 'value' => 12.1, 'text' => '12.1 GB'],
        ['label' => 'Documents', 'value' => 5, 'text' => '5 GB'],
        ['label' => 'Other', 'value' => 2, 'text' => '2 GB', 'color' => 'muted'],
    ]"
/>

Props

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

<x-widget.progress>

Prop Default Description
value 0
max 100 Progress is value out of max: :value="3" :max="5" for files, bytes, money, anything countable.
label null
show-label false
label-position 'above' Where the label and value go: above the bar, beside it on one line, or inside a thicker bar.
value-text null Shown instead of the percentage, and read out by screen readers: "3 of 5 files", "1.2 GB of 5 GB".
failed false
disabled false
size 'md'
indeterminate false The amount isn't known yet: a sliding bar and no value. (A prop, not :value="null": Blade turns a passed null back into the default.)
striped false

<x-widget.progress.circular>

Prop Default Description
value 0
max 100
label null
show-label false
show-value true
value-text null Read out by screen readers and shown under the label, e.g. "3 of 5 files". The centre keeps the percentage.
failed false
disabled false
size 'md'
indeterminate false

<x-widget.progress.stacked>

Prop Default Description
segments [] Parts of one whole: [['label' => 'Photos', 'value' => 18.4, 'text' => '18.4 GB'], …]. text is optional; color is optional too (primary, link, warning, error, muted) and otherwise follows that order.
max 100 The whole the parts are measured against, e.g. 50 for a 50 GB plan. What's left over shows as free space.
label 'Usage'
summary null A summary beside the label, e.g. "37.5 GB of 50 GB used".
legend true
rest 'Free' Legend name for the space left over; null leaves it out.

Source

What larawell:add progress 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/progress/circular.blade.php Show
circular.blade.php
@props([
    'value' => 0,
    'max' => 100,
    'label' => null,
    'showLabel' => false,
    'showValue' => true,
    // Read out by screen readers and shown under the label, e.g. "3 of 5 files". The centre keeps the percentage.
    'valueText' => null,
    'failed' => false,
    'disabled' => false,
    'size' => 'md',
    'indeterminate' => false,
])

@php
    // indeterminate: the amount isn't known yet, so a spinning arc and no aria-valuenow. (A prop, not
    // :value="null": Blade turns a passed null back into the default.)
    $max = max((float) $max, 0.0) ?: 100.0;
    $value = max(0.0, min($max, (float) $value));
    $percent = (int) round($value / $max * 100);
    $number = rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
    $complete = ! $indeterminate && ! $failed && ! $disabled && $value >= $max;

    // Box, ring thickness (in viewBox units, so it scales with the box) and centre text, per size.
    $sizes = [
        'sm' => ['box' => 'size-10', 'stroke' => 10, 'text' => 'text-[10px]'],
        'md' => ['box' => 'size-16', 'stroke' => 9, 'text' => 'text-sm'],
        'lg' => ['box' => 'size-24', 'stroke' => 8, 'text' => 'text-lg'],
    ];
    ['box' => $box, 'stroke' => $stroke, 'text' => $text] = $sizes[$size] ?? $sizes['md'];
    $radius = 50 - $stroke / 2;

    // Same colours per state as the linear <x-widget.progress>, completion included (data-complete).
    $arc = match (true) {
        $disabled => 'stroke-muted opacity-50',
        $failed => 'stroke-error',
        default => 'stroke-primary group-data-complete/progress:stroke-success',
    };
    $state = match (true) {
        $disabled => ', disabled',
        $failed => ', failed',
        default => '',
    };
@endphp

<div {{ $attributes->class(['inline-flex flex-col items-center gap-2']) }}>
    <div
        role="progressbar"
        data-progress="circular"
        aria-valuemin="0"
        aria-valuemax="{{ $max }}"
        @unless ($indeterminate)
            aria-valuenow="{{ $number }}"
            aria-valuetext="{{ $valueText ?? $percent.'%' }}{{ $state }}"
        @endunless
        aria-label="{{ $label ?? 'Progress' }}"
        @if ($indeterminate) data-indeterminate @endif
        @if ($complete) data-complete @endif
        @if (! $indeterminate && $percent === 0) data-empty @endif
        @if ($failed) data-failed @endif
        @if ($disabled) aria-disabled="true" @endif
        class="group/progress {{ $box }} relative shrink-0"
    >
        {{-- pathLength="100" makes the dash maths plain percentages; -rotate-90 starts the arc at 12 o'clock.
             --progress is the one dynamic value; progress.set() updates it and the transition animates it. --}}
        <svg viewBox="0 0 100 100" aria-hidden="true" class="size-full -rotate-90 group-data-indeterminate/progress:animate-spin motion-reduce:group-data-indeterminate/progress:animate-none">
            <circle cx="50" cy="50" r="{{ $radius }}" fill="none" stroke-width="{{ $stroke }}" class="stroke-field" />
            <circle
                data-progress-fill
                cx="50" cy="50" r="{{ $radius }}" fill="none" stroke-width="{{ $stroke }}"
                stroke-linecap="round" pathLength="100" stroke-dasharray="100"
                @class([
                    $arc,
                    // --progress as a class from the ranges at the end of base.css, not style="".
                    '[--progress:'.($indeterminate ? 25 : $percent).'] [stroke-dashoffset:calc(100-var(--progress))]',
                    'transition-[stroke-dashoffset,stroke,opacity] duration-500 ease-in-out motion-reduce:transition-none',
                    // A round cap would draw a dot at 0%; progress.set() toggles data-empty the same way.
                    'group-data-empty/progress:opacity-0',
                ])
            />
        </svg>

        @if ($showValue)
            <span data-progress-percent aria-hidden="true" class="{{ $text }} text-foreground absolute inset-0 grid place-items-center font-semibold tabular-nums group-data-indeterminate/progress:hidden">{{ $percent }}%</span>
        @endif
    </div>

    @if ($showLabel && $label)
        <span class="text-foreground text-center text-xs">{{ $label }}</span>
    @endif
    @if ($valueText !== null)
        <span data-progress-value class="text-foreground/60 text-center text-xs tabular-nums">{{ $valueText }}</span>
    @endif
</div><?php /* No newline after this: PHP drops it after a closing tag, so no space trails the component in running text. */ ?>
resources/views/components/widget/progress/index.blade.php Show
index.blade.php
@props([
    'value' => 0,
    // Progress is value out of max: :value="3" :max="5" for files, bytes, money, anything countable.
    'max' => 100,
    'label' => null,
    'showLabel' => false,
    // Where the label and value go: above the bar, beside it on one line, or inside a thicker bar.
    'labelPosition' => 'above',
    // Shown instead of the percentage, and read out by screen readers: "3 of 5 files", "1.2 GB of 5 GB".
    'valueText' => null,
    'failed' => false,
    'disabled' => false,
    'size' => 'md',
    // The amount isn't known yet: a sliding bar and no value. (A prop, not :value="null": Blade turns a passed null back into the default.)
    'indeterminate' => false,
    'striped' => false,
])

@php
    $max = max((float) $max, 0.0) ?: 100.0;
    $value = max(0.0, min($max, (float) $value));
    $percent = (int) round($value / $max * 100);
    $inside = $labelPosition === 'inside';
    $heights = ['sm' => 'h-1.5', 'md' => 'h-3', 'lg' => 'h-4'];
    $height = $inside ? 'h-6' : ($heights[$size] ?? $heights['md']);
    $text = $valueText ?? "{$percent}%";
    $complete = ! $indeterminate && ! $failed && ! $disabled && $value >= $max;

    // Base colour per state; reaching max turns it green through data-complete, which resources/js/widget/progress
    // also sets, so the colour follows progress.set() too.
    $fill = match (true) {
        $disabled => 'bg-muted opacity-50',
        $failed => 'bg-error',
        default => 'bg-primary group-data-complete/progress:bg-success',
    };
    $state = match (true) {
        $disabled => ', disabled',
        $failed => ', failed',
        default => '',
    };
    $number = rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
@endphp

<div {{ $attributes->class(['w-full', 'flex items-center gap-3' => $labelPosition === 'beside' && $showLabel]) }}>
    @if ($showLabel && $labelPosition === 'above')
        <div class="text-foreground mb-1.5 flex items-center justify-between gap-2 text-xs">
            <span>{{ $label }}</span>
            <span data-progress-value class="text-foreground/60 tabular-nums" @if ($indeterminate) hidden @endif>{{ $text }}</span>
        </div>
    @elseif ($showLabel && $labelPosition === 'beside' && $label)
        <span class="text-foreground shrink-0 text-xs">{{ $label }}</span>
    @endif

    <div
        role="progressbar"
        data-progress="linear"
        aria-valuemin="0"
        aria-valuemax="{{ $max }}"
        @unless ($indeterminate)
            aria-valuenow="{{ $number }}"
            aria-valuetext="{{ $text }}{{ $state }}"
        @endunless
        aria-label="{{ $label ?? 'Progress' }}"
        @if ($indeterminate) data-indeterminate @endif
        @if ($complete) data-complete @endif
        @if ($failed) data-failed @endif
        @if ($disabled) aria-disabled="true" @endif
        @class(['group/progress bg-field relative w-full min-w-0 overflow-hidden rounded-[14px]', $height, '@container' => $inside])
    >
        {{-- Inline width is the one dynamic value; progress.set() updates it and the transition animates it. --}}
        <div
            data-progress-fill
            @class([
                $fill,
                'absolute inset-y-0 start-0 rounded-[10px] transition-[width,background-color] duration-500 ease-in-out motion-reduce:transition-none',
                'bg-[linear-gradient(45deg,rgb(255_255_255/0.25)_25%,transparent_25%,transparent_50%,rgb(255_255_255/0.25)_50%,rgb(255_255_255/0.25)_75%,transparent_75%,transparent)] bg-size-[1rem_1rem] animate-progress-stripes motion-reduce:animate-none' => $striped,
                // Indeterminate: a short bar sliding across. Reduced motion gets a still bar instead.
                'group-data-indeterminate/progress:w-2/5! group-data-indeterminate/progress:animate-progress-slide rtl:group-data-indeterminate/progress:animate-progress-slide-rtl motion-reduce:group-data-indeterminate/progress:animate-none',
                // A class, not style="": see the ranges at the end of base.css.
                "w-[{$percent}%]",
                // Inside labels: the fill clips its own white copy of the label (below).
                'overflow-hidden' => $inside,
            ])
        >
            {{-- No one colour reads on both a dark fill and the light track, so the label is drawn twice: dark over
                 the track, and this white copy, as wide as the whole bar (100cqw), cut off where the fill ends.
                 Not for an indeterminate bar, whose fill slides and would carry the copy with it. --}}
            @if ($inside && ! $indeterminate)
                <div aria-hidden="true" class="text-on-primary absolute inset-y-0 start-0 flex w-[100cqw] items-center justify-between gap-2 px-3 text-xs font-medium">
                    <span class="truncate">{{ $showLabel ? $label : '' }}</span>
                    <span data-progress-value class="shrink-0 tabular-nums">{{ $text }}</span>
                </div>
            @endif
        </div>

        @if ($inside)
            {{-- The dark copy, for the track. It sits under the fill, which covers it with the white copy as it grows. --}}
            <div class="text-foreground flex h-full items-center justify-between gap-2 px-3 text-xs font-medium">
                <span class="truncate">{{ $showLabel ? $label : '' }}</span>
                <span data-progress-value class="shrink-0 tabular-nums" @if ($indeterminate) hidden @endif>{{ $text }}</span>
            </div>
        @endif
    </div>

    @if ($showLabel && $labelPosition === 'beside')
        <span data-progress-value class="text-foreground/60 shrink-0 text-xs tabular-nums" @if ($indeterminate) hidden @endif>{{ $text }}</span>
    @endif
</div>
resources/views/components/widget/progress/stacked.blade.php Show
stacked.blade.php
@props([
    // Parts of one whole: [['label' => 'Photos', 'value' => 18.4, 'text' => '18.4 GB'], …]. text is optional;
    // color is optional too (primary, link, warning, error, muted) and otherwise follows that order.
    'segments' => [],
    // The whole the parts are measured against, e.g. 50 for a 50 GB plan. What's left over shows as free space.
    'max' => 100,
    'label' => 'Usage',
    // A summary beside the label, e.g. "37.5 GB of 50 GB used".
    'summary' => null,
    'legend' => true,
    // Legend name for the space left over; null leaves it out.
    'rest' => 'Free',
])

@php
    // Written out in full so Tailwind finds them.
    $colors = ['primary' => 'bg-primary', 'link' => 'bg-link', 'warning' => 'bg-warning', 'error' => 'bg-error', 'muted' => 'bg-muted'];
    $palette = array_keys($colors);
    $max = max((float) $max, 0.0) ?: 100.0;

    $used = 0.0;
    $parts = [];
    foreach (array_values($segments) as $i => $segment) {
        // Parts can't add up to more than the whole, so later ones are trimmed to what's left.
        $value = max(0.0, min((float) ($segment['value'] ?? 0), $max - $used));
        $used += $value;
        $percent = round($value / $max * 100, 2);
        $parts[] = [
            'label' => (string) ($segment['label'] ?? ''),
            'percent' => $percent,
            'text' => (string) ($segment['text'] ?? round($percent).'%'),
            'color' => $colors[$segment['color'] ?? $palette[$i % count($palette)]] ?? $colors['primary'],
        ];
    }
    $restPercent = round(max(0.0, 100 - $used / $max * 100), 2);

    // The bar is a picture of the legend, so it's named with the same words.
    $description = collect($parts)->map(fn (array $part): string => "{$part['label']} {$part['text']}")
        ->when($rest !== null && $restPercent > 0, fn ($items) => $items->push($rest.' '.round($restPercent).'%'))
        ->implode(', ');
@endphp

<div {{ $attributes->class(['w-full']) }}>
    <div class="text-foreground mb-2 flex items-baseline justify-between gap-2 text-xs">
        <span class="font-medium">{{ $label }}</span>
        @if ($summary)
            <span class="text-foreground/60 tabular-nums">{{ $summary }}</span>
        @endif
    </div>

    <div role="img" aria-label="{{ $label }}: {{ $description }}" class="bg-field flex h-3 w-full gap-0.5 overflow-hidden rounded-full">
        @foreach ($parts as $part)
            @if ($part['percent'] > 0)
                {{-- Whole percents from the ranges at the end of base.css; min-w-0.5 keeps a sliver under 1% visible. --}}
                <div class="{{ $part['color'] }} w-[{{ max(0, min(100, (int) round($part['percent']))) }}%] h-full min-w-0.5 transition-[width] duration-500 motion-reduce:transition-none"></div>
            @endif
        @endforeach
    </div>

    @if ($legend)
        <ul class="mt-3 flex flex-wrap gap-x-5 gap-y-1.5 text-xs" aria-hidden="true">
            @foreach ($parts as $part)
                <li class="flex items-center gap-1.5">
                    <span class="{{ $part['color'] }} size-2.5 shrink-0 rounded-full"></span>
                    <span class="text-foreground">{{ $part['label'] }}</span>
                    <span class="text-foreground/60 tabular-nums">{{ $part['text'] }}</span>
                </li>
            @endforeach
            @if ($rest !== null && $restPercent > 0)
                <li class="flex items-center gap-1.5">
                    <span class="bg-field border-line size-2.5 shrink-0 rounded-full border"></span>
                    <span class="text-foreground">{{ $rest }}</span>
                    <span class="text-foreground/60 tabular-nums">{{ round($restPercent) }}%</span>
                </li>
            @endif
        </ul>
    @endif
</div>
resources/js/widget/progress/index.js Show
index.js
// progress.set(target, value, { text }) updates <x-widget.progress> or <x-widget.progress.circular>:
// the fill, the visible value and what screen readers hear, together. Setting style.width by hand
// would leave aria-valuenow behind. target: the element, its id, or anything inside the component.
//
//   progress.set('upload', 60);
//   progress.set('upload', 3, { text: '3 of 5 files' });   // value out of the bar's max
//
// An indeterminate bar becomes a normal one on its first set().

function find(target) {
    const el = typeof target === 'string' ? document.getElementById(target) : target;

    return el?.closest?.('[role=progressbar][data-progress]') ?? el?.querySelector?.('[role=progressbar][data-progress]') ?? null;
}

function set(target, value, { text } = {}) {
    const bar = find(target);
    if (!bar) {
        return;
    }
    const max = Number(bar.getAttribute('aria-valuemax')) || 100;
    const clamped = Math.min(max, Math.max(0, Number(value) || 0));
    const percent = Math.round((clamped / max) * 100);
    const label = text ?? `${percent}%`;
    const state = bar.hasAttribute('data-failed') ? ', failed' : bar.getAttribute('aria-disabled') === 'true' ? ', disabled' : '';

    bar.removeAttribute('data-indeterminate');
    bar.setAttribute('aria-valuenow', String(clamped));
    bar.setAttribute('aria-valuetext', label + state);
    bar.toggleAttribute('data-complete', clamped >= max && !state);
    bar.toggleAttribute('data-empty', percent === 0);

    const fill = bar.querySelector('[data-progress-fill]');
    if (bar.dataset.progress === 'circular') {
        fill?.style.setProperty('--progress', String(percent));
        const centre = bar.querySelector('[data-progress-percent]');
        if (centre) {
            centre.textContent = `${percent}%`;
        }
    } else if (fill) {
        fill.style.width = `${percent}%`;
    }

    // The visible value sits beside, above or inside the bar, so look in the whole component.
    bar.parentElement.querySelectorAll('[data-progress-value]').forEach((el) => {
        el.textContent = label;
        el.hidden = false;
    });
}

export const progress = { set };

// Global so inline handlers and other scripts can call progress.set().
window.progress = progress;