Compositor-Safe Progress Bars and Meters

Part of Compositor-Only Property Optimization in Performance Budgeting & GPU Architecture.

The problem

An upload dialog shows a progress bar whose fill animates by transitioning width from 0% to the current percentage. With three concurrent uploads and progress events arriving several times a second, the dialog stutters — each width change lays out the bar and everything after it, and each update restarts a transition. A circular variant animates stroke-dashoffset, which repaints the ring on every frame.

Progress is a continuously updating value, which makes it exactly the case where the property choice matters most.

Root cause analysis: width is layout, scale is composite

width animation is layout per frame. The fill’s box changes size, so the browser lays out the fill, its parent and anything whose position depends on it, then repaints. Multiply by several bars and several updates per second and the cost is real.

scaleX is a transform. Give the fill the full track width and scale it horizontally from 0 to the fraction complete, with transform-origin: left. The fill is painted once at full width; the compositor scales it each frame. Nothing is laid out.

Scaling distorts children. A percentage label inside the fill would be stretched. Either place the label outside the fill — usually better, since it should stay readable at 2% — or counter-scale it with the inverse factor, which requires the scale value in a custom property.

Rounded ends need care. A fill with border-radius: 999px scaled to 5% has squashed, elliptical ends. Put the radius on the track with overflow: clip, so the fill is a plain rectangle clipped to a rounded shape.

Circular meters have two options. stroke-dashoffset on an SVG circle is the common technique and repaints the ring each frame; for a single small ring updated a few times a second that is fine. A conic-gradient driven by a registered angle property also repaints. For continuous animation, rotating a half-ring mask in two composited layers is the compositor-safe version, at the cost of significantly more markup.

Update frequency matters more than technique. Progress events can arrive dozens of times a second. Updating the custom property on every event queues style work far more often than the display refreshes; throttling to about four updates a second looks identical and costs a fraction.

Main-thread time per second, three bars updating 10 times a secondMid-range phone, 400 px wide bars inside a dialog.Main-thread time per second, three bars updating 10 times a secondwidth transition per update61 msscaleX transition per update7 msscaleX, throttled to 4 per second3 ms
Mid-range phone, 400 px wide bars inside a dialog.

Step-by-step resolution

Converting a progress barScale the fill, clip the track, throttle the updates.Converting a progress bar1Give the fill inline-size: 100% and transform-origin: left.A full-width box to scale2Drive scale from a --progress custom property between 0 and 1.One value to update3Move the radius to the track and add overflow: clip.Rounded ends without distortion4Transition scale with a short duration so updates glide.Smooth between values5Throttle updates to about four per second.Fewer style recalculations6Mark up with progress or role=progressbar and keep aria-valuenow current.
Scale the fill, clip the track, throttle the updates.

Production code pattern

<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"
     aria-label="Uploading photos" style="--progress: 0">
  <div class="progress__fill"></div>
</div>
<output class="progress__label">0%</output>
.progress {
  block-size: 8px;
  border-radius: 999px;                 /* radius on the TRACK */
  overflow: clip;                       /* fill is clipped to the rounded shape */
  background: var(--color-surface-2);
}

.progress__fill {
  block-size: 100%;
  inline-size: 100%;                    /* full width, then scaled */
  transform-origin: left;
  scale: var(--progress, 0) 1;
  background: var(--color-primary-600);
  transition: scale var(--duration-300) var(--ease-standard);
}

@media (prefers-reduced-motion: reduce) {
  .progress__fill { transition: none; }  /* value still updates, just without gliding */
}
const bar = document.querySelector('.progress');
const label = document.querySelector('.progress__label');
let pending = null, timer = 0;

function setProgress(fraction) {
  pending = Math.min(1, Math.max(0, fraction));
  if (timer) return;                                    // throttle: at most 4 updates a second
  timer = setTimeout(() => {
    timer = 0;
    const pct = Math.round(pending * 100);
    bar.style.setProperty('--progress', String(pending));
    bar.setAttribute('aria-valuenow', String(pct));
    label.textContent = `${pct}%`;
  }, 250);
}

upload.addEventListener('progress', (e) => setProgress(e.loaded / e.total));

Rendering Impact: composite. The fill is painted once and scaled; each throttled update costs one style recalculation. The label is outside the fill, so it is never distorted.

Keeping the label outside the fill also avoids the counter-scale entirely. Where a label genuinely must sit inside a scaling element, apply scale: calc(1 / var(--progress)) 1 to it — and guard against division by zero at --progress: 0.

Two ways to fill a progress barSame pixels on screen; different pipeline stages.Two ways to fill a progress barwidth: 0% to 64%Layout for the fill and its siblingsRepaint of the barCosts multiply with concurrent barsLayout per framescale: 0 to 0.64 with clipping trackFill painted once at full widthCompositor scales the textureTrack clips the rounded endsComposite per frame
Same pixels on screen; different pipeline stages.

Circular meters

For a ring that updates a few times a second, stroke-dashoffset on an SVG circle with pathLength="1" is simple and accurate: set stroke-dashoffset: calc(1 - var(--progress)) and transition it. It repaints the ring, which is a thin annulus, so the cost is small — and it is the same technique as drawing SVG lines with stroke-dashoffset.

Reach for a composited alternative only when the ring animates continuously, such as an indeterminate spinner, in which case rotate a static arc rather than animating its geometry. A conic-gradient driven by a registered <angle> looks the same but repaints per frame, which is the trade-off covered in animating gradients with registered properties.

Verification checklist

Constraints and trade-offs

  • A scaled fill has a texture the width of the track, even at 2% progress.
  • Counter-scaling is fragile at very low progress values.
  • Gradients inside a scaled fill are stretched with it.
  • Throttling adds up to a quarter second of lag between event and display.
  • Indeterminate progress still needs an infinite animation, which should pause when off-screen.

Frequently asked questions

Why is animating width bad for progress bars?

It lays out the fill and its surroundings on every frame. Scaling a full-width fill moves the work to the compositor.

How do I keep rounded ends from squashing?

Put the border radius on the track with overflow: clip, so the fill itself is a rectangle clipped to a rounded shape.

How often should progress update?

About four times a second is indistinguishable from continuous updates and costs far less style work.

What about circular progress rings?

stroke-dashoffset on an SVG circle with pathLength is simple and cheap for occasional updates; use a rotating static arc for continuous indeterminate motion.