<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 />
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.
<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
Show code Hide code
<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.
Show code Hide code
<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.
Show code Hide code
<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().
Show code Hide code
<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>
// 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.
Show code Hide code
<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.
Show code Hide code
<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
@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
@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
@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
// 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;