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.

The element announces itself; the help text says what the number means

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.

role="img" with a label, because the shape carries the meaning
<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.

The label is a sibling of the ring, not a child
68% 24%
<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.

Submitting Rebuilding the index
<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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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 omitted value is announced as indeterminate. Every other option on this page has to have that added by hand.
  • Label it. A <progress> needs a <label for> or aria-label like 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 .help line, 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-live region 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.

Fills from the right
<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. .ring and .chart-meter exist for the cases where it would not; those cases are rarer than they look.