<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-fill 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 focus-visible:ring-offset-surface"
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
|
How far along it is, out of max. Reaching max turns it green. |
| max |
100
|
Progress is value out of max: :value="3" :max="5" for files, bytes, money, anything countable. |
| label |
null
|
Its name for screen readers ("Progress" if left out); shown too with show-label. |
| show-label |
false
|
Shows the label and the value next to the bar, placed by label-position. Without it only screen readers get them (label-position="inside" still shows the value). |
| 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
|
Turns it red, e.g. for an upload that stopped, and screen readers hear "failed". |
| disabled |
false
|
Greyed out, e.g. for a paused job, and screen readers hear "disabled". |
| size |
'md'
|
How thick the bar is: sm, md or lg. label-position="inside" always uses a thicker bar. |
| 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
|
Moving diagonal stripes on the filled part, to show work is going on. They stay still for people who prefer reduced motion. |
<x-widget.progress.circular>
| Prop | Default | Description |
|---|---|---|
| value |
0
|
How far along it is, out of max. Reaching max turns it green. |
| max |
100
|
Progress is value out of max: :value="3" :max="5" for files, bytes, money, anything countable. |
| label |
null
|
Its name for screen readers ("Progress" if left out); shown too with show-label. |
| show-label |
false
|
Shows the label under the circle. |
| show-value |
true
|
The percentage in the centre of the circle. |
| 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
|
Turns it red, e.g. for an upload that stopped, and screen readers hear "failed". |
| disabled |
false
|
Greyed out, e.g. for a paused job, and screen readers hear "disabled". |
| size |
'md'
|
How big the circle is: sm, md or lg. |
| indeterminate |
false
|
The amount isn't known yet: a spinning arc and no value. (A prop, not :value="null": Blade turns a passed null back into the default.) |
<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'
|
The heading above the bar, and its name for screen readers. |
| summary |
null
|
A summary beside the label, e.g. "37.5 GB of 50 GB used". |
| legend |
true
|
Lists each part's colour, name and amount under the bar; false leaves it out. |
| rest |
'Free'
|
Legend name for the space left over; null leaves it out. |
Accessibility
All 6 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.
Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.
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([
// How far along it is, out of max. Reaching max turns it green.
'value' => 0,
// Progress is value out of max: :value="3" :max="5" for files, bytes, money, anything countable.
'max' => 100,
// Its name for screen readers ("Progress" if left out); shown too with show-label.
'label' => null,
// Shows the label under the circle.
'showLabel' => false,
// The percentage in the centre of the circle.
'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,
// Turns it red, e.g. for an upload that stopped, and screen readers hear "failed".
'failed' => false,
// Greyed out, e.g. for a paused job, and screen readers hear "disabled".
'disabled' => false,
// How big the circle is: sm, md or lg.
'size' => 'md',
// The amount isn't known yet: a spinning arc and no value. (A prop, not :value="null": Blade turns a passed null
// back into the default.)
'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'],
];
// A typo fails loudly, naming the values that work, instead of quietly rendering something else.
if (! array_key_exists($size, $sizes)) {
throw new \InvalidArgumentException("Unknown size [{$size}] for <x-widget.progress.circular>. Use one of: ".implode(', ', array_keys($sizes)).'.');
}
['box' => $box, 'stroke' => $stroke, 'text' => $text] = $sizes[$size];
$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([
// How far along it is, out of max. Reaching max turns it green.
'value' => 0,
// Progress is value out of max: :value="3" :max="5" for files, bytes, money, anything countable.
'max' => 100,
// Its name for screen readers ("Progress" if left out); shown too with show-label.
'label' => null,
// Shows the label and the value next to the bar, placed by label-position. Without it only screen readers get them
// (label-position="inside" still shows the value).
'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,
// Turns it red, e.g. for an upload that stopped, and screen readers hear "failed".
'failed' => false,
// Greyed out, e.g. for a paused job, and screen readers hear "disabled".
'disabled' => false,
// How thick the bar is: sm, md or lg. label-position="inside" always uses a thicker bar.
'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,
// Moving diagonal stripes on the filled part, to show work is going on. They stay still for people who
// prefer reduced motion.
'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);
$heights = ['sm' => 'h-1.5', 'md' => 'h-3', 'lg' => 'h-4'];
// A typo fails loudly, naming the values that work, instead of quietly rendering something else.
if (! in_array($labelPosition, ['above', 'beside', 'inside'], true)) {
throw new \InvalidArgumentException("Unknown label-position [{$labelPosition}] for <x-widget.progress>. Use one of: above, beside, inside.");
}
if (! array_key_exists($size, $heights)) {
throw new \InvalidArgumentException("Unknown size [{$size}] for <x-widget.progress>. Use one of: ".implode(', ', array_keys($heights)).'.');
}
$inside = $labelPosition === 'inside';
$height = $inside ? 'h-6' : $heights[$size];
$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.
// With the label inside, white text sits on the bar, so it takes the deeper fill shades (they differ in dark mode).
$fill = match (true) {
$disabled => 'bg-muted opacity-50',
$failed => $inside ? 'bg-error-fill' : 'bg-error',
$inside => 'bg-primary-fill group-data-complete/progress:bg-success-fill',
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,
// The heading above the bar, and its name for screen readers.
'label' => 'Usage',
// A summary beside the label, e.g. "37.5 GB of 50 GB used".
'summary' => null,
// Lists each part's colour, name and amount under the bar; false leaves it out.
'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;