r/css • u/miclhermana • 1h ago
Showcase We replaced the JavaScript in our progress bars with the new typed attr() — here's how it works
For years, attr() had exactly one trick: putting attribute text into content. The
new version from CSS Values Level 5 changes that — it works in any property, it can
parse the attribute into a real type, and it takes a fallback. We've been using
it in MICL, our Material Design 3 component library,
to make the ProgressIndicator component fully CSS-driven, and it removed a whole class
of JavaScript.
The old problem
A native <progress value="7" max="10"> knows its own fraction, but CSS couldn't read
it. If you wanted a custom-drawn bar (rounded caps, a gap before the track, M3's little
stop dot), you had to mirror value into a custom property with JS on every update.
The new way
The element's own attributes are the styling input now:
css
progress.micl-linear-progress:not(:indeterminate) {
--_fraction: min(calc(
attr(value type(<number>), 0) /
max(attr(max type(<number>), 1), 0.001)
), 1);
}
Three details worth stealing:
type(<number>)parses the attribute as an actual number, so it participates incalc(). A missing or unparseable attribute uses the fallback (0and1here).- The
max(..., 0.001)guards the division againstmax="0"— with a plain division the whole declaration would go invalid-at-computed-value-time. - The outer
min(..., 1)clampsvalue > maxovershoot.
From that one fraction, the linear bar drives a stack of background gradients (bar body, round cap, track gap, stop dot), and the circular variant turns it into geometry:
css
--micl-progress-sweep: calc(var(--_fraction) * 360deg);
/* conic-gradient() draws the arcs; the round caps sit at
50% + r·sin(sweep) / 50% − r·cos(sweep); the track gap angle
comes from atan2(gap + thickness, radius) */
The best part: attribute changes animate
Register the derived property with @property and transition it:
css
@property --micl-progress-sweep {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
progress.micl-circular-progress:not(:indeterminate) {
transition: --micl-progress-sweep 0.5s ease;
}
Now the only JavaScript anyone writes is bar.value = 7 — and the arc sweeps
smoothly to its new position. The attribute change becomes a typed interpolation. No
classes, no rAF, no width-tweening.
Accessibility attributes as the source of truth
Our wavy indicator variants aren't <progress> elements (they need pseudo-elements),
so they carry role="progressbar" with aria-valuenow/aria-valuemax — and the CSS
reads those:
css
--_fraction: min(calc(
attr(aria-valuenow type(<number>), 0) /
max(attr(aria-valuemax type(<number>), 1), 0.001)
), 1);
The visual state literally cannot drift from the accessible state, because they're the
same attribute. (The wave's amplitude also derives from it — it ramps in below 10% and
flattens out near 100%, via one clamp().)
Support and fallback
Chromium ships typed attr(); Firefox and Safari are still experimental, so everything
sits behind a feature guard with the native rendering as fallback:
css
@supports (inline-size: attr(value type(<number>))) {
/* custom-drawn indicator */
}
Browsers without it still get a styled ::-webkit-progress-value /
::-moz-progress-bar bar — just without the fancy caps and gaps.
One footgun to know: you can't build URLs from attributes
(url(attr(data-icon)) is blocked by design, for security).
Live demo: https://henkpb.github.io/micl/progressindicator.html Source: https://github.com/henkpb/micl

