Lesson 5
Advanced attr() - HTML Attributes as Typed CSS Values
You've learned how var() pulls values from custom property declarations. attr() does something similar - but the source is an HTML attribute on the element itself, rather than a CSS declaration.
For years, attr() was limited to one use: reading an attribute as a string for the content property of pseudo-elements. The advanced version (Chrome 133+) removes both restrictions. You can now use attr() with any property and parse values into any CSS type.
The old attr() - strings only, content only
This is what you may have seen before:
Old attr() - tooltip via content property
Hover this text to see the attribute value used as content.
<span class="tooltip-old" data-tip="I'm a tooltip!">Hover this text</span>
.tooltip-old::after {
content: attr(data-tip); /* always a string, only works in content */
}
That's useful but limited. The value is always a string, and it can only appear in content:.
The new attr() - typed values, any property
The advanced syntax adds a type and a fallback:
attr( <attribute-name> type(<css-type>), <fallback> )
Compare this to what you already know:
| Function | Source | Syntax |
|---|---|---|
var() |
Custom property declaration | var(--name, fallback) |
attr() |
HTML attribute on element | attr(name type(<T>), fallback) |
The parallel: var() reads from the cascade. attr() reads from the DOM. Both support fallbacks. Both can now be typed. Both work anywhere a value is expected.
Syntax breakdown
/* Read data-width as a percentage, fallback to 50% */
width: attr(data-width type(<percentage>), 50%);
/* Read data-color as a colour */
background: attr(data-color type(<color>), gray);
/* Read data-size as a length */
font-size: attr(data-size type(<length>), 1rem);
/* Read data-cols as an integer */
grid-template-columns: repeat(attr(data-cols type(<integer>), 3), 1fr);
The type() wrapper uses the same type names you learned in @property:
type(<length>),type(<percentage>),type(<number>),type(<integer>)type(<color>),type(<angle>),type(<time>)type(<length-percentage>),type(<custom-ident>)
If you omit type(), it still behaves as the old string-only version (backward compatible).
Live demo: bar chart from data attributes
Each bar reads its width from data-width - no custom properties or JS needed
<div class="bar" data-width="85%">85%</div>
<div class="bar" data-width="62%">62%</div>
<div class="bar" data-width="94%">94%</div>
<div class="bar" data-width="41%">41%</div>
.bar {
width: attr(data-width type(<percentage>), 10%);
height: 2rem;
background: var(--accent);
}
Why this matters: The data lives in the HTML. No JavaScript needed to set widths. A server can render the attributes, a CMS can populate them, or you can generate them from a framework binding ([attr.data-width] in Angular). The CSS just reads them.
Live demo: colours from attributes
Background colour read directly from data-color attribute
Blue Coral Teal Purple<span class="color-chip" data-color="#3a86ff">Blue</span>
<span class="color-chip" data-color="#e76f51">Coral</span>
<span class="color-chip" data-color="#2a9d8f">Teal</span>
<span class="color-chip" data-color="#6c5ce7">Purple</span>
.color-chip {
background: attr(data-color type(<color>), #ccc);
}
Using attr() inside calc()
Since typed attr() returns a real CSS value (not a string), it composes with everything you've learned:
<div class="grid-item" data-pad="8px">...</div>
<div class="grid-item" data-pad="16px">...</div>
<div class="grid-item" data-pad="4px">...</div>
.grid-item {
/* Double the padding from the attribute */
padding: calc(attr(data-pad type(<length>), 8px) * 2);
}
Padding doubled via calc() on attr()
<div class="grid-item" data-pad="8px">...</div>
<div class="grid-item" data-pad="16px">...</div>
<div class="grid-item" data-pad="4px">...</div>
.grid-item {
/* Double the padding from the attribute */
padding: calc(attr(data-pad type(<length>), 8px) * 2);
}
Fallbacks: var() inside attr()
The fallback value can itself be a var() call, giving you a chain: attribute → custom property → hardcoded default:
<div class="themed-box" data-radius="20px" data-bg="#fff3e0">...</div>
<div class="themed-box">...</div> <!-- no attributes -->
.themed-box {
--default-radius: 8px;
border-radius: attr(data-radius type(<length>), var(--default-radius));
background: attr(data-bg type(<color>), #f0f7ff);
/* First box: 20px radius, orange background (from attributes)
Second box: 8px radius, light blue background (from fallbacks) */
}
First box has data-radius="20px", second has no attribute (uses --default-radius)
<div class="themed-box" data-radius="20px" data-bg="#fff3e0">...</div>
<div class="themed-box">No attributes - uses custom property defaults</div>
.themed-box {
--default-radius: 8px;
border-radius: attr(data-radius type(<length>), var(--default-radius));
background: attr(data-bg type(<color>), #f0f7ff);
}
When to use attr() vs custom properties
They solve different problems:
| Use attr() when... | Use custom properties when... |
|---|---|
| The value comes from the HTML/server/CMS | The value is a design decision (token) |
| Each element has a unique value | Many elements share the same value |
| The value is data-driven (charts, progress bars) | The value is theme-driven (colours, spacing) |
| You want to avoid inline styles | You want cascade inheritance |
Angular connection: In Angular, you'd bind like [attr.data-width]="item.progress + '%'". The CSS reads it with attr(data-width type(<percentage>)). No [ngStyle] or inline styles needed - cleaner separation of concerns.
What attr() can't do
- No inheritance.
attr()reads from the element itself, not its ancestors. Unlike custom properties, there's no cascade. - No animated transitions. If you change an attribute in the DOM (e.g. via
element.setAttribute('data-width', '80%')), CSS will pick up the new value - it's reactive, not frozen. But the change is an instant swap. You can't smoothly transition between the old and new values, because the browser's transition system tracks CSS properties, not DOM attributes. It doesn't know "this attribute just went from X to Y, let me interpolate." - Attribute must exist in the HTML. If the attribute is missing AND no fallback is provided, the declaration becomes invalid at computed-value time.
- Only reads the element's own attributes. You can't read a parent's attribute from a child's CSS rule.
Workaround: attr() + @property for animated transitions
If you need both data-attribute-sourced values AND smooth transitions, bridge the gap with a registered custom property and a tiny bit of JS:
/* Register a typed custom property so transitions work */
@property --width {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.bar {
width: var(--width);
transition: --width 0.3s ease;
}
// JS reads the attribute and writes it into the custom property
const bar = document.querySelector('.bar');
bar.style.setProperty('--width', bar.dataset.width);
// Later, when the data changes:
bar.dataset.width = '80%';
bar.style.setProperty('--width', '80%'); // triggers smooth transition
The attribute is still the source of truth (your server/framework sets it), but JS bridges it into a registered custom property that the browser knows how to interpolate. This is a common pattern in data-driven UIs where you want both server-driven values and polished animation.
Practical patterns
Progress bar without JS
Width derived from data-value / data-max - pure CSS
<progress-bar data-value="73" data-max="100"></progress-bar>
<progress-bar data-value="45" data-max="100"></progress-bar>
<progress-bar data-value="91" data-max="100"></progress-bar>
progress-bar {
--pct: calc(attr(data-value type(<number>), 0) / attr(data-max type(<number>), 100) * 100%);
display: block;
height: 8px;
background: lightgray;
border-radius: 4px;
margin-bottom: 0.5rem;
}
progress-bar::after {
content: "";
display: block;
height: 100%;
width: var(--pct);
background: var(--accent);
border-radius: 4px;
}
Responsive grid columns from markup
Column count read from data-cols attribute
<div class="attr-grid" data-cols="4">
<div class="attr-grid-cell">1</div>
<div class="attr-grid-cell">2</div>
<div class="attr-grid-cell">3</div>
<div class="attr-grid-cell">4</div>
</div>
.attr-grid {
display: grid;
grid-template-columns: repeat(attr(data-cols type(<integer>), 3), 1fr);
gap: 0.5rem;
}
Icon sizing from attributes
Size and colour from data attributes - no inline styles
<svg-icon data-size="16px" data-color="#3a86ff">▶</svg-icon>
<svg-icon data-size="24px" data-color="#e76f51">▶</svg-icon>
<svg-icon data-size="32px" data-color="#2a9d8f">▶</svg-icon>
<svg-icon data-size="48px" data-color="#6c5ce7">▶</svg-icon>
svg-icon {
width: attr(data-size type(<length>), 16px);
height: attr(data-size type(<length>), 16px);
color: attr(data-color type(<color>), currentColor);
}
Retrieval check
Question 1
What was the old limitation of attr() before the upgrade?
Question 2
How do you tell the browser that data-size should be parsed as a length?
Question 3
Can you transition a value read from attr()?
Question 4
An element has no data-size attribute. You use: font-size: attr(data-size type(<length>), 1rem). What happens?
Question 5
A child element wants to read data-theme from its parent. Can it use attr(data-theme)?