Primitives Feedback
Progress
The native progress element: how much of a task is done, or that a task runs when its size is unknown. The browser owns the value, the indeterminate state and the progressbar role; the package adds a stylesheet and a contract. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
<progress>,:indeterminate,appearance: none,::-webkit-progress-value- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
<progress value max>
On this page
Example
<label for="progress-upload">Uploading report.pdf</label>
<progress id="progress-upload" data-slean="progress" value="40" max="100">40%</progress>
<label for="progress-sync">Syncing the calendar</label>
<progress id="progress-sync" data-slean="progress"></progress><script lang="ts">
// Nothing to import at runtime: the browser owns the progress. The stylesheet is optional.
import '@svelte-lean/styles/progress.css';
</script>
<label for="progress-upload">Uploading report.pdf</label>
<progress id="progress-upload" data-slean="progress" value="40" max="100">40%</progress>
<label for="progress-sync">Syncing the calendar</label>
<progress id="progress-sync" data-slean="progress"></progress>Why this implementation exists
The platform has had a progress element with a role, a value and an indeterminate state for years. What it lacks is a way to draw it that matches a design system: its look is the operating system’s, and the parts that draw the value are vendor pseudo-elements.
Svelte Lean ships the drawing. progress.css sets appearance: none and colors the track and the value through ::-webkit-progress-value and ::-moz-progress-bar. The indeterminate state is a striped track, so it reads as busy with or without motion.
The browser owns
- the value, the maximum and the indeterminate state without a value
- the progressbar role with its name and value
- the direction of the fill under RTL
Svelte Lean owns
- progress.css: the track, the value through the vendor pseudo-elements, the sizes
- the indeterminate stripes, still under reduced motion
- the contract
- documentation
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@svelte-lean/styles/progress.css';Give the progress a name with <label for>, aria-labelledby or aria-label. Set max and update value as the task runs; leave value out while the size is unknown.
A progress does not announce its changes. When the task ends, say so in a status message (see Alert).
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <progress data-slean="progress" value max> | – | yes | No value attribute: indeterminate. data-size="sm|lg". A label through <label for>, aria-labelledby or aria-label. |
Runtime profile
Tier 0: the progress has no behavior module, the Vite plugin maps progress to no module, and the page ships no Svelte Lean JavaScript for it. The indeterminate animation is CSS.
Accessibility contract
- The browser exposes the
progressbarrole with the value; do not addroleoraria-value*attributes. - A name through
<label for>(the element is labelable),aria-labelledbyoraria-label. - A region that is loading carries
aria-busy="true"andaria-describedbypointing to the progress. - The element is not focusable. Screen readers read the value when the reader reaches it; changes are not announced.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab | anywhere | Skips the progress: it is not focusable (native) |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
<progress>, :indeterminate | Widely available | Not applicable |
appearance: none | Baseline 2022 | The platform’s own bar |
::-webkit-progress-value, ::-moz-progress-bar | Not standardized; each engine has one of the two | Not applicable |
:dir() | Baseline 2023 | The stripes move toward the right under RTL as well |
Without JavaScript
The rendered value is visible and the indeterminate stripes move, since they are CSS. The value changes only when the application’s script, or a new page, changes it.
Server rendering
The value and max attributes render the state; a progress rendered without value is indeterminate until the application sets one.
Before hydration
Nothing is attached. The progress rendered on the server is the element the application updates after hydration.
Styling
progress.css makes the element the track, draws the value in currentColor (the accent) through the vendor pseudo-elements, transitions the value where the engine allows it, and draws the indeterminate state as stripes of the accent over its soft tint, moved by a keyframe animation whose duration comes from the tokens. Under forced colors the track gets a border and the value Highlight. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-radius-full | 9999px | border-radius |
--slean-muted | var(--slean-neutral-3) | background |
--slean-accent | oklch(54% 0.19 258) | color |
--slean-duration-slow | 240ms | transition, animation |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-accent-soft | oklch(95% 0.03 258) | background-image |
State selectors the stylesheet targets, all from the platform or ARIA: ::-moz-progress-bar, ::-webkit-progress-bar, ::-webkit-progress-value, :dir(rtl), :indeterminate.
Variant attributes: data-size (sm, lg).
Controlled integration
The application owns the value. Bind it to the task’s own progress, here an upload; until the size is known the attributes are left out and the element is indeterminate.
<script lang="ts">
// The application owns the value. Until the size is known the progress has no value and
// is indeterminate; undefined removes the attribute.
let loaded = $state(0);
let total = $state(0);
function upload(file: File) {
const request = new XMLHttpRequest();
request.upload.onprogress = (event) => {
loaded = event.loaded;
total = event.lengthComputable ? event.total : 0;
};
request.open('POST', '/api/files');
request.send(file);
}
</script>
<label for="upload">Uploading</label>
<progress
id="upload"
data-slean="progress"
value={total ? loaded : undefined}
max={total || undefined}
></progress>Compatibility notes
The element and :indeterminate are widely available. appearance: none is Baseline 2022. The pseudo-elements that draw the value are not standardized, and every engine has one of the two the stylesheet uses. :dir() (Baseline 2023) reverses the stripes under RTL.
Examples
Sizes
data-size="sm" and data-size="lg" change the thickness of the track; the
default sits between them.
<progress data-slean="progress" data-size="sm" value="70" max="100" aria-label="Small"></progress>
<progress data-slean="progress" value="70" max="100" aria-label="Default"></progress>
<progress data-slean="progress" data-size="lg" value="70" max="100" aria-label="Large"></progress>A region that is loading
When the progress stands for a region of the page, the region carries aria-busy="true" until its content is in place and points to the progress with aria-describedby, as the ARIA progressbar role describes.
<section aria-labelledby="invoices-title" aria-busy="true" aria-describedby="invoices-progress">
<h2 id="invoices-title">Invoices</h2>
<progress id="invoices-progress" data-slean="progress" aria-label="Loading the invoices"></progress>
<!-- the rows arrive here; then aria-busy is removed -->
</section>Testing
apps/playground/tests/primitives/feedback-navigation.spec.tsPlaywright: roles, names and states in the accessibility tree, keyboard, RTL, reduced motion, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/progress/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/progress/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/progress.cssthe optional stylesheet