Progress

Use a determinate bar whenever you know how much is left — it is the only one that tells the reader anything. Omit value and the bar animates instead, which says "still working" and nothing more.

Determinate

stack({ gap: 'lg' },
  progress({ value: 25 }),
  progress({ value: 60 }),
  progress({ value: 100 }),
)

Indeterminate

progress()

A bar with no label is marked aria-hidden — a progressbar role with no accessible name tells a screen reader nothing, so an unlabelled bar is treated as decoration. Label anything a reader is meant to follow.

Labels

A label names what is happening; showValue adds the percentage on the right.

Rendering pages72%
Optimising images50%
Waiting for the deploy
stack({ gap: 'lg' },
  progress({ value: 72, label: 'Rendering pages', showValue: true }),
  progress({ value: 30, max: 60, label: 'Optimising images', showValue: true }),
  progress({ label: 'Waiting for the deploy' }),
)

Colors and height

Passed80%
Degraded45%
Failing20%
stack({ gap: 'lg' },
  progress({ value: 80, color: 'success', label: 'Passed', showValue: true }),
  progress({ value: 45, color: 'warning', label: 'Degraded', showValue: true }),
  progress({ value: 20, color: 'danger', label: 'Failing', showValue: true }),
  progress({ value: 60, color: 'neutral', height: 'xs' }),
  progress({ value: 60, color: 'primary', height: '1rem' }),
)

A scale other than 100

max lets you pass the raw numbers — pages built out of pages total — instead of working out a percentage first.

118 of 169 pages70%
progress({ value: 118, max: 169, label: '118 of 169 pages', showValue: true })

Moving it from the browser

A bar is server-rendered HTML: the percentage is a custom property on the fill and a number in aria-valuenow, and nothing on the page changes either on its own. Give the bar an id and setProgress moves both together — the fill, the announced value, and the percentage beside the label.

import { setProgress } from 'sitelo/ui/client'

const request = new XMLHttpRequest()

request.upload.addEventListener('progress', (event) => {
  setProgress('upload', event.loaded, { max: event.total })
})

The maximum is remembered, so later calls are just a value. Or reach the module the way the components reach theirs, and skip the bundle entirely:

button({ onclick: "import('/su/progress.js').then(m=>m.set('upload',100))" }, 'Finish')

Passing null — or anything that is not a finite number — hands the bar back to the indeterminate animation, so work that stops reporting numbers does not have to be special-cased. getProgress() reads the current value back, on the bar’s own scale.

Try it

This page loads the runtime, so the buttons below really do move the bar.

Uploading0%
stack({ gap: 'md' },
  progress({ id: 'demo-progress', value: 0, label: 'Uploading', showValue: true }),
  stack({ direction: 'row', gap: 'sm', wrap: true },
    button({ size: 'sm', variant: 'outline', onclick: "import('/su/progress.js').then(m=>m.set('demo-progress',0))" }, 'Reset'),
    button({ size: 'sm', variant: 'outline', onclick: "import('/su/progress.js').then(m=>m.set('demo-progress',35))" }, '35%'),
    button({ size: 'sm', variant: 'outline', onclick: "import('/su/progress.js').then(m=>m.set('demo-progress',80))" }, '80%'),
    button({ size: 'sm', variant: 'outline', onclick: "import('/su/progress.js').then(m=>m.set('demo-progress',100))" }, 'Done'),
    button({ size: 'sm', variant: 'ghost', onclick: "import('/su/progress.js').then(m=>m.set('demo-progress',null))" }, 'Unknown'),
  ),
)

An unlabelled bar is moved too, but it stays aria-hidden — it was rendered without a name on purpose, and announcing a value on it now would put a nameless progressbar in the accessibility tree.

Spinner

There is no spinner component — the spinner is an icon, and spin is what turns it. Like every icon it is sized in em, so it matches whatever text it sits beside without being told a size.

stack({ direction: 'row', gap: 'lg', align: 'center' },
  icon('spinner', { spin: true, size: 'sm' }),
  icon('spinner', { spin: true }),
  icon('spinner', { spin: true, size: 'lg' }),
)

Spinner in context

Give a standalone spinner a label so it is announced. One inside a button does not need one — the button already says what it is doing.

Fetching the latest build…

stack({ gap: 'md' },
  stack({ direction: 'row', gap: 'sm', align: 'center' },
    icon('spinner', { spin: true, label: 'Loading' }),
    text({ variant: 'small', tone: 'muted' }, 'Fetching the latest build…'),
  ),
  stack({ direction: 'row', gap: 'sm' },
    button({ loading: true }, 'Deploying'),
    button({ variant: 'outline', loading: true }, 'Checking links'),
  ),
)

Props

progress() — exported as progressBar too:

PropTypeDefaultDescription
valuenumber—How far along. Omit for the indeterminate animation.
maxnumber100What value counts as complete.
color'primary' | 'neutral' | 'success' | 'warning' | 'danger''primary'Fill colour.
labelChild—Text above the bar; also its accessible name.
showValuebooleanfalseShow the percentage beside the label.
heightSpace'0.5rem'Bar thickness.

setProgress() from sitelo/ui/client:

PropTypeDefaultDescription
targetElement | string—The bar, or the id of one. A selector is tried if no element has that id.
valuenumber | null—Where to move it. null returns it to the indeterminate animation.
options.maxnumber100What counts as complete. Remembered for the calls after it.

The spinner has no props of its own — it is icon('spinner', { spin: true }), and takes whatever icon() takes.