# Progress

> The native <progress> as a bar or a ring, determinate and indeterminate; the busy indicator is a circular progress.

Page: https://svelte-lean.vercel.app/primitives/progress · Markdown: https://svelte-lean.vercel.app/primitives/progress.md · Index: https://svelte-lean.vercel.app/llms.txt

The native progress element as a bar or a ring: how much of a task is done, or that a task runs while its size is unknown.

```svelte
<script lang="ts">
	// No runtime: the browser owns the progress. The stylesheet is optional.
	import '@svelte-lean/styles/progress.css';
</script>

<div class="tasks">
	<label for="report">Uploading report.pdf</label>
	<progress id="report" data-slean="progress" value="40" max="100"></progress>

	<!-- No value: indeterminate. -->
	<label for="calendar">Syncing the calendar</label>
	<progress id="calendar" data-slean="progress"></progress>
</div>

<style>
	.tasks {
		display: grid;
		gap: 0.5rem;
	}
</style>
```

The second progress has no value, so it is indeterminate: moving stripes that stay drawn under reduced motion.

## Examples

### Circular

```svelte
<script lang="ts">
	import '@svelte-lean/styles/progress.css';

	let done = $state(65);
</script>

<!-- Indeterminate: the busy indicator. -->
<progress data-slean="progress" data-variant="circular" data-size="small" aria-label="Loading"></progress>
<progress data-slean="progress" data-variant="circular" aria-label="Loading"></progress>
<progress data-slean="progress" data-variant="circular" data-size="large" aria-label="Loading"></progress>

<!-- Determinate: value for the browser, --slean-value for the drawing. -->
<progress
	data-slean="progress"
	data-variant="circular"
	data-size="large"
	value={done}
	max="100"
	style="--slean-value: {done}"
	aria-label="Uploading photos.zip"
></progress>
<span>{done}%</span>
```

Without a value the ring turns: the busy indicator. A determinate ring also needs its percentage in --slean-value.

### Busy status line

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/progress.css';

	let loading = $state(false);

	function refresh() {
		loading = true;
		// Stands in for a request.
		setTimeout(() => (loading = false), 1800);
	}
</script>

<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" onclick={refresh}>
	Refresh
</button>
<!-- In the page before the wait: the text written into it is announced. The ring is decoration. -->
<p class="status" role="status">
	{#if loading}
		<progress data-slean="progress" data-variant="circular" data-size="small" aria-hidden="true"></progress>
		Loading the comments
	{/if}
</p>

<style>
	.status {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		min-block-size: 1.5rem;
	}
</style>
```

A status region that is in the page before the wait announces the text written into it; the small ring beside it is decoration.

### Looks

```svelte
<script lang="ts">
	import '@svelte-lean/styles/progress.css';

	const colors = ['primary', 'secondary', 'error', 'warning', 'info', 'success', 'neutral'];
</script>

<!-- linear is the default variant, primary the default color. A ring's value is also --slean-value. -->
{#each colors as color, i}
	<progress data-slean="progress" data-color={color} value={30 + i * 10} max="100" aria-label={color}></progress>
{/each}
{#each colors as color, i}
	<progress data-slean="progress" data-variant="circular" data-color={color} value={30 + i * 10} max="100"
		style="--slean-value: {30 + i * 10}" aria-label={color}></progress>
{/each}
```

linear (the default) and circular in every color; without a value both show the busy animation.

### Upload

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/progress.css';

	let done = $state(0);
	let timer: ReturnType<typeof setInterval> | undefined;

	function start() {
		clearInterval(timer);
		done = 0;
		timer = setInterval(() => {
			done = Math.min(100, done + 5);
			if (done === 100) clearInterval(timer);
		}, 150);
	}

	$effect(() => () => clearInterval(timer));
</script>

<div class="upload">
	<label for="photos">Uploading photos.zip: {done}%</label>
	<progress id="photos" data-slean="progress" value={done} max="100"></progress>
	<!-- A progress does not announce its end; a status message does. -->
	<p role="status">{done === 100 ? 'Upload complete.' : ''}</p>
	<button type="button" data-slean="button" onclick={start}>Start upload</button>
</div>

<style>
	.upload {
		display: grid;
		justify-items: start;
		gap: 0.5rem;
	}

	.upload progress {
		inline-size: 100%;
	}
</style>
```

The value follows the task; a status message announces the end.

### Loading region

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/progress.css';

	let busy = $state(false);

	function reload() {
		busy = true;
		setTimeout(() => (busy = false), 1500);
	}
</script>

<section
	aria-labelledby="invoices-title"
	aria-busy={busy}
	aria-describedby={busy ? 'invoices-progress' : undefined}
>
	<h4 id="invoices-title">Invoices</h4>
	{#if busy}
		<progress id="invoices-progress" data-slean="progress" aria-label="Loading the invoices"
		></progress>
	{:else}
		<ul>
			<li>INV-1042 · Northwind</li>
			<li>INV-1043 · Globex</li>
		</ul>
	{/if}
	<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" onclick={reload} disabled={busy}>
		Reload
	</button>
</section>
```

While it loads, the region carries aria-busy and points to the progress with aria-describedby.

### Sizes

```svelte
<script lang="ts">
	import '@svelte-lean/styles/progress.css';
</script>

<div class="sizes">
	<progress data-slean="progress" data-size="small" 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="large" value="70" max="100" aria-label="Large"></progress>
</div>

<style>
	.sizes {
		display: grid;
		gap: 1rem;
	}
</style>
```

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/progress.css';
```

Name the progress 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).

`data-variant="circular"` draws a ring. A determinate ring needs `style="--slean-value: 40"` next to `value="40"` (percent, without the unit): CSS cannot read the attribute as a number in every engine yet. A small ring beside text that already says what runs is `aria-hidden="true"`.

Inside a button, use the button’s `aria-busy="true"` instead; a progress in a button adds its name to the button’s.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<progress data-slean="progress" value max>` | – | yes | No value attribute: indeterminate. data-variant="linear\|circular", data-size="small\|medium\|large", data-color. A label through \<label for\>, aria-labelledby or aria-label; a determinate ring also takes style="--slean-value: \<percent\>". |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab` | anywhere | Skips the progress: it is not focusable (native) |

## Styling

`progress.css` makes the element the track, draws the value in the current color (`data-color`, primary by default) through the vendor pseudo-elements, transitions the value where the engine allows it, and draws the indeterminate state as stripes of the color over its soft tint, moved by a keyframe animation whose duration comes from the tokens. The circular variant is a conic gradient masked to a ring whose thickness is a fifth of its radius; indeterminate, a quarter arc turned with `--slean-duration-indicator`, which reduced motion slows instead of stopping. `data-size` (small, medium, large) changes the bar’s thickness or the ring’s diameter. 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-color-main` | `var(--slean-primary-main)` | color |
| `--slean-duration-slow` | `240ms` | transition, animation |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-color-soft` | `var(--slean-primary-soft)` | background-image |
| `--slean-value` | `0` | background |
| `--slean-duration-indicator` | `750ms` | animation |

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` (`small`, `large`); `data-variant` (`circular`).

## Controlled integration

Bind the value to the task’s own progress, here an upload request; until the size is known the attributes are left out and the element is indeterminate.

`upload.svelte`

```svelte
<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>
```

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<progress>`, `:indeterminate`, `appearance: none`, `::-webkit-progress-value`, `conic-gradient()`, `mask`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<progress value max>`

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.

Why this implementation

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.

`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.

A spinner is a progress whose size is unknown, drawn as a ring, so it is the same element with `data-variant="circular"` (ADR 0011 §1.10): the native element already has the busy semantics (a `progressbar` without a value). The ring is a conic gradient on the element, masked to a ring; no extra markup and no script.

### 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, colors and sizes
- the indeterminate stripes, still under reduced motion
- the circular variant: a masked conic gradient, its value from --slean-value, a turning quarter arc while indeterminate
- the contract
- documentation

Accessibility

- The browser exposes the `progressbar` role with the value; do not add `role` or `aria-value*` attributes.
- A name through `<label for>` (the element is labelable), `aria-labelledby` or `aria-label`.
- A region that is loading carries `aria-busy="true"` and `aria-describedby` pointing to the progress.
- The element is not focusable. Screen readers read the value when the reader reaches it; changes are not announced.

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 |
| `conic-gradient(), mask` | Baseline 2020 and Baseline 2023 | The ring is not drawn as a ring |
| `: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 and hydration

The `value` and `max` attributes render the state; a progress rendered without `value` is indeterminate until the application sets one.

Nothing is attached. The progress rendered on the server is the element the application updates after hydration.

Compatibility

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. The ring uses `conic-gradient()` (Baseline 2020) and unprefixed `mask` (Baseline 2023). `:dir()` (Baseline 2023) reverses the stripes under RTL.

Tests and source

- `apps/playground/tests/primitives/feedback-navigation.spec.ts` Playwright: roles, names and states in the accessibility tree, keyboard, RTL, reduced motion, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/progress/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/progress/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/progress.css` the optional stylesheet
