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.
Step-by-step resolution
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.
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.
Related
- Compositor-Only Property Optimization — the parent topic
- SVG Spinners and Loaders That Stay Smooth — indeterminate indicators
- Animating Backgrounds with Pseudo-Element Layers — the same substitution for backgrounds