Using HTML Attribute Values Inside CSS With attr()
Keeping a number in an HTML attribute and copying it into an inline style feels like unnecessary bookkeeping. I like CSS attr() for that reason: the stylesheet can read the attribute directly, for generated text or a calculation.
Generated labels have broad browser support. Typed values outside content need a separate support check; MDN still describes that use as experimental. That makes me more cautious about using the newer syntax for an essential layout.
Read a label with attr()
<a href="/report.pdf" data-type="PDF">Download report</a>
a[data-type]::after {
content: " (" attr(data-type) ")";
}
The link displays “Download report (PDF)”. The pseudo-element reads data-type from the originating link, so the label stays next to the element that owns the attribute.
I would keep generated text to supplementary hints. Essential information belongs in the HTML text itself: MDN’s data-attribute guidance warns that assistive technology may not access content stored only in data attributes.
Give numeric attributes a type
An attribute value starts as text. The typed form tells CSS how to interpret it before using it in a calculation:
<div class="bar" data-progress="72"></div>
.bar {
--progress: attr(data-progress type(<number>), 0);
width: calc(var(--progress) * 1%);
height: 1rem;
background: #3366ff;
}
The number 72 becomes 72% after multiplication by 1%. Setting width directly to the unitless result would be invalid: the width property needs a length or percentage for a numeric width.
The fallback after the comma is used when the attribute is missing or cannot be parsed as the requested type. Here, both cases produce zero, according to the typed attribute rules.
A parsed number can still fall outside the range your component expects. For example, 150 is a valid number even if a progress value should stop at 100.
Turn a rating into a visible fill
A rating is a useful case because the number already belongs to the content. I would keep the score as readable text, then let the attribute control a background:
<div class="rating" data-rating="4">4 out of 5</div>
.rating {
background: #eee;
padding: 0.5rem;
}
@supports (opacity: attr(data-rating type(<number>), 0)) {
.rating {
--rating: clamp(0, attr(data-rating type(<number>), 0), 5);
--fill: calc(var(--rating) / 5 * 100%);
background: linear-gradient(
to right,
#fde68a var(--fill),
#eee var(--fill)
);
}
}
With a rating of four, the coloured part covers 80% of the element’s width. The calculation clamps the input between zero and five before converting it to a percentage.
Browsers without typed attr() support keep the plain background and the readable score. The visual fill adds context without becoming the only place the rating is shown.
Avoid maintaining a second copy for CSS
Without typed attr(), a component might carry the same number in two attributes:
<div class="bar" data-progress="72" style="--progress: 72"></div>
I would be comfortable with that duplication when a template generates both values. Keeping two copies in sync by hand is a less appealing job. The attraction of attr() is being able to remove one of them when browser support allows it.
Reading data-progress directly removes the inline CSS copy in supporting browsers. The component still needs a readable value or suitable semantics when the number means something to the reader.
Keep data and presentation in their own roles
HTML data attributes are useful for element-specific metadata. CSS can read a score or category to choose a visual treatment.
A data attribute does not give a plain element the semantics of a progress indicator. For actual task progress, consider the HTML progress element before building a custom bar.
The boundary I would keep is between showing a value and deciding what that value means. Clamping a visual fill can keep it inside a box; it does not repair an incorrect value in the underlying data. Validation still belongs outside the stylesheet.
Handle unsupported syntax and bad data separately
A base declaration handles browsers that cannot use the typed syntax. The fallback inside attr() handles missing or invalid attribute values in browsers that can:
.bar {
width: 50%;
height: 1rem;
background: #3366ff;
}
@supports (width: calc(attr(data-progress type(<number>), 50) * 1%)) {
.bar {
width: clamp(0%, calc(attr(data-progress type(<number>), 50) * 1%), 100%);
}
}
Both fallbacks use 50% in this demonstration so their effect is easy to compare. An attribute of 72 produces 72%; a missing or non-numeric value produces 50%.
That arbitrary fallback is suitable for demonstrating the CSS behaviour. A real progress indicator should show the actual value, or explicitly represent an unknown state.
The attr() compatibility table distinguishes the broadly supported function from its newer syntax. Check the typed form against the browsers your project needs.
Choose custom properties when CSS owns the value
CSS custom properties remain useful when a value comes from the stylesheet. Unregistered custom properties inherit, and JavaScript can update them through the element’s style.
I would reach for attr() when the attribute is already the right home for the data. For a value that only describes presentation, a custom property feels more direct. Adding a data attribute solely to avoid an inline CSS variable usually just moves the same styling decision elsewhere.
Enjoyed this article?
If you found it useful, share it with a friend or someone who might find it helpful too. Thanks for reading!
Related articles
- What the Browser Already Does Without a Framework
Before adding a frontend framework, check what HTML, CSS, forms, responsive images, preferences, and browser navigation already provide.
- When CSS Layout Order Stops Matching the HTML
Keep Grid and Flexbox layouts aligned with the HTML order so keyboard focus and screen reader reading do not jump around the page.
- Show Form Errors After Interaction With :user-invalid
Use :user-invalid and :user-valid to style native HTML validation states without marking untouched fields as errors.