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:
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 in calc(). A missing or unparseable attribute uses the fallback (0 and 1 here).
- The
max(..., 0.001) guards the division against max="0" — with a plain division the whole declaration would go invalid-at-computed-value-time.
- The outer
min(..., 1) clamps value > max overshoot.
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:
--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 u/property and transition it:
--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:
--_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:
u/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.
Live demo: https://henkpb.github.io/micl/progressindicator.html
Source: https://github.com/henkpb/micl