sveltelean Primitives
Versionv0.2.0 GitHub

Example

Determinate and indeterminate
40%
<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>
Tier 0: the two sources differ only by the stylesheet import. The second progress has no value, so it is indeterminate: stripes that move, and stay drawn when the reader prefers reduced motion. Everything here works with page JavaScript disabled.

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/styles
stylesheets
import '@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

PartElementdata-slean-partRequiredNotes
root<progress data-slean="progress" value max>–yesNo 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 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.

Keyboard

KeyWhenResult
TabanywhereSkips the progress: it is not focusable (native)

Platform features

FeatureBaselineOutside the target
<progress>, :indeterminateWidely availableNot applicable
appearance: noneBaseline 2022The platform’s own bar
::-webkit-progress-value, ::-moz-progress-barNot standardized; each engine has one of the twoNot applicable
:dir()Baseline 2023The 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:

TokenDefault (light)Applies to
--slean-radius-full9999pxborder-radius
--slean-mutedvar(--slean-neutral-3)background
--slean-accentoklch(54% 0.19 258)color
--slean-duration-slow240mstransition, animation
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-accent-softoklch(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.

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

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.

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

invoices
<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

Source