Progress
Three ways to show that something is happening, and the choice between them comes
down to one question: do you know how much is left? If you do, use a
<progress> or a ring. If you do not, use a spinner — and do not
fake a percentage.
.progress — the native element
.progress goes on a real <progress>. The element
carries the value, the maximum and the accessible role, and Deck restyles the two
vendor pseudo-elements — the same two-engines problem as
.range, so the declarations are written
twice.
68% — 1,687 of 2,481 rows
<div class="stack stack-3" style="max-inline-size:26rem">
<div class="field">
<label class="label" for="dx-prog">Export</label>
<progress class="progress" id="dx-prog" value="68" max="100"></progress>
<p class="help">68% — 1,687 of 2,481 rows</p>
</div>
</div>
<div class="stack stack-3" style="max-inline-size:26rem">
<div class="field">
<label class="label" for="dx-prog">Export</label>
<progress class="progress" id="dx-prog" value="68" max="100"></progress>
<p class="help">68% — 1,687 of 2,481 rows</p>
</div>
</div>
A <progress> with no value is indeterminate and the
browser animates it — which is the correct way to say "working, duration unknown"
while keeping the element's semantics.
.ring — circular
A conic gradient from --value, with a radial
mask punching out the middle. Three properties shape it:
--value, --size and --thickness. Use it where
a bar would be too wide — inside a card, beside a stat.
<div class="ring" style="--value:68" role="img" aria-label="68 per cent complete"></div>
<div class="ring" style="--value:24" role="img" aria-label="24 per cent complete"></div>
<div class="ring" style="--value:92;--size:72px;--thickness:10px" role="img" aria-label="92 per cent complete"></div>
<div class="ring" style="--value:68" role="img" aria-label="68 per cent complete"></div>
<div class="ring" style="--value:24" role="img" aria-label="24 per cent complete"></div>
<div class="ring" style="--value:92;--size:72px;--thickness:10px" role="img" aria-label="92 per cent complete"></div>
The mask is why a figure cannot go inside the ring. A mask applies to an element
and its whole subtree, so a <span> centred in a
.ring is punched out along with the middle — it renders, takes up space,
and is completely invisible. .ring-wrap and .ring-label are
the way round it: the wrapper is an unmasked inline-grid that stacks the
label over the ring as a sibling.
<span class="ring-wrap">
<span class="ring" style="--value:68"></span>
<span class="ring-label">68%</span>
</span>
<span class="ring-wrap">
<span class="ring" style="--value:24"></span>
<span class="ring-label">24%</span>
</span>
<span class="ring-wrap">
<span class="ring" style="--value:68"></span>
<span class="ring-label">68%</span>
</span>
<span class="ring-wrap">
<span class="ring" style="--value:24"></span>
<span class="ring-label">24%</span>
</span>
A ring is a <div>, not a <progress>. It has no
role and no value unless you give it one — role="img" with
aria-label, or role="progressbar" with
aria-valuenow if the number will change. With
.ring-wrap the visible label carries it instead, which is better still.
.ring itself still sets place-items: center,
font-size, font-weight and
font-variant-numeric — four declarations that style a child the mask
guarantees nobody will see. They are the fossil of the approach that does not work,
and are recorded in FINDINGS.md for removal.
A bar with no element
Deck ships no standalone meter. Where you need a bar drawn from a value rather than
from a <progress> — a quota inside a table cell, a fill in a series
colour — that is .chart-meter, documented on the
charts page.
Prefer the element wherever it fits. A <div> that looks like
progress is progress nobody can hear.
.spinner — no known end
A rotating border. .spinner-lg is the 34px version. Use it when the
duration is genuinely unknown; a spinner that runs for forty seconds tells the reader
nothing about whether to wait.
<span class="cluster cluster-tight"><span class="spinner"></span> Submitting</span>
<span class="cluster cluster-tight"><span class="spinner spinner-lg"></span> Rebuilding the index</span>
<span class="cluster cluster-tight"><span class="spinner"></span> Submitting</span>
<span class="cluster cluster-tight"><span class="spinner spinner-lg"></span> Rebuilding the index</span>
.spinner is one of seven things exempted from the reduced-motion reset in
src/02-reset.css: instead of freezing, it slows to 2.4 seconds. The
reasoning in the source is that a frozen spinner reads as a hung page — which is worse
for everyone than a slow one.
Classes
.progress, .ring and .spinner are three
separate classes in src/07-components.css rather than one component
root, so there is no completeness check on this page.
| Class | Layer | What it does | Source |
|---|---|---|---|
.progress |
deck.components |
Three ways to say "something is happening", and the choice is only ever about one thing: whether you know how much is left. .progress and .ring do, .spinner does not. There is no standalone meter -- a bar drawn from a value rather than from an element is .chart-meter, in 14-charts.css. .progress goes on a real <progress>, so the element carries the value, the maximum and the role. The two vendor pseudo-elements have to be written as separate rules: a vendor pseudo-element in a shared selector invalidates the whole rule for the other engine. Same constraint as .range. | 07-components.css:509 |
.ring |
deck.components |
A conic gradient for the fill, a radial mask for the hole. Three custom properties shape it: --value, --size, --thickness. The mask is the thing to understand. It applies to this element AND its whole subtree, so a label centred inside a .ring is punched out along with the middle -- it renders, it takes up space, it is in the accessibility tree, and it is invisible. That is what .ring-wrap and .ring-label below exist for: an unmasked wrapper that stacks the label over the ring as a sibling rather than a child. FINDINGS 60: place-items and the three font declarations here are the fossil of the approach that does not work. They style a child nobody can see -- .ring-label repeats all three on the sibling, which is the tell. Due for removal; kept for now because the API is frozen. | 07-components.css:536 |
.ring-wrap |
deck.components |
Sets position, display, place-items. | 07-components.css:552 |
.ring-label |
deck.components |
No declarations. | 07-components.css:553 |
.spinner |
deck.components |
Exempt from the reduced-motion reset in 02-reset.css: instead of freezing it slows to 2.4s. A frozen spinner reads as a hung page, which is worse for everyone than a slow one. Note border-block-start-color rather than border-top-color -- the lit segment starts at the block start in any writing mode. | 07-components.css:560 |
.spinner-lg |
deck.components |
Sets inline-size, block-size, border-width. | 07-components.css:568 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--ink-200 |
oklch(91.2% calc(var(--chroma-neutral) * 1.8) var(--hue-neutral)) |
|
--brand-600 |
oklch(51.6% var(--chroma-brand) var(--hue-brand)) |
|
--r-full |
999px |
|
--text-xs |
.75rem |
|
--dur-3 |
300ms |
|
--ease-out |
cubic-bezier(.22, 1, .36, 1) |
Accessibility
-
The element gives you everything.
<progress>is announced as a progress bar with its value, updates are conveyed, and an omittedvalueis announced as indeterminate. Every other option on this page has to have that added by hand. -
Label it. A
<progress>needs a<label for>oraria-labellike any other control — "68%" with no idea what is at 68% is not information. -
Show the number in text. The bar is a picture of a figure; the
figure is what a reader acts on. Put it in a
.helpline, as the first example does. -
A spinner announces nothing. It is a styled empty element. Pair it
with text — the examples all do — and put that text in an
aria-liveregion if the state changes without a page load. - Do not fake determinate progress. A bar that crawls to 90% and waits is a lie the reader learns to distrust. If you do not know, say so.
- Long operations need more than an indicator. Past a few seconds a reader wants to know what is happening and whether they can leave. That is text, not a bar.
- Colour alone. All four are a coloured fill against a track, with no pattern or label. Where the exact value matters — a quota near its limit — the number has to be written out.
Right to left
A <progress> fills from the right under dir="rtl" with
no help from Deck — the engine mirrors its own pseudo-elements. A ring does not mirror,
which is also right: a clock face reads the same way in every direction.
A dead line in the source. src/19-logical.css lists
.progress::-webkit-progress-value inside an :is() alongside
the toast timer and the chart bars. A pseudo-element is not
valid inside :is(), and :is() parses forgivingly, so the
browser drops it and keeps the rest — read the rule back through
cssRules and it serialises as
[dir="rtl"] :is(.chart-bar-fill, .toast-timer). Nothing breaks, because
the fill is animated with inline-size and has no transform for
transform-origin to act on. It is recorded in
FINDINGS.md so it is removed rather than trusted.
<div dir="rtl" class="field" style="max-inline-size:24rem">
<label class="label" for="dx-prog-rtl">التصدير</label>
<progress class="progress" id="dx-prog-rtl" value="68" max="100"></progress>
</div>
<div dir="rtl" class="field" style="max-inline-size:24rem">
<label class="label" for="dx-prog-rtl">التصدير</label>
<progress class="progress" id="dx-prog-rtl" value="68" max="100"></progress>
</div>
Reduced motion
.progress's value transitions over
300ms and the global reset collapses it.
.spinner is exempt and keeps turning slowly. An indeterminate
<progress> is animated by the browser and is outside Deck's reach —
which is the right place for it, since the platform knows what its own reduced-motion
setting should do.
Printing
None of these are in src/99-print.css. A <progress>
prints as whatever the browser draws for it; the ring is a background and the spinner
is a border mid-rotation, so both print as an empty circle.
That is another reason to write the number beside the bar: on paper the number is all that survives.
Overriding it
/* One bar, no layer needed */
<progress class="progress" style="block-size:14px" value="68" max="100"></progress>
@layer app.components {
/* Both engines, or neither */
.progress::-webkit-progress-value { background: var(--good-500); }
.progress::-moz-progress-bar { background: var(--good-500); }
}
As with the range slider, a vendor pseudo-element in a shared selector invalidates the whole rule for the other engine. Write them separately.
When not to use it
- Not a spinner when you know the total. "Uploading 3 of 12" is better than a spinner in every respect.
- Not a bar when you do not. See above, in reverse.
-
Not for a rating or a score. A progress bar implies something is
under way. Use
.rating, or a chart bar. - Not inside a toast for a long job. The toast leaves. Put a bar in the page where the reader can find it again.
-
Not a
<div>where the element would work..ringand.chart-meterexist for the cases where it would not; those cases are rarer than they look.