← Back to Index

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:

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

85%
62%
94%
41%
<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);
}
Ad

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()

data-pad="8px" → padding: 16px
data-pad="16px" → padding: 32px
data-pad="4px" → padding: 8px
<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)

data-radius="20px", data-bg="#fff3e0"
No attributes - uses custom property defaults
<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

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

1
2
3
4
1
2
<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?

It only worked on data- attributes
It only returned strings and only worked in content:
It only worked in Chrome browsers
It required JavaScript to function

Question 2

How do you tell the browser that data-size should be parsed as a length?

attr(data-size, <length>)
attr(data-size as length)
attr(data-size type(<length>), fallback)
attr(data-size, type: length)

Question 3

Can you transition a value read from attr()?

Yes - once typed, it interpolates like any value
No - attributes aren't tracked for interpolation
Only if you also register it with @property
Only for colour and length types

Question 4

An element has no data-size attribute. You use: font-size: attr(data-size type(<length>), 1rem). What happens?

The declaration is skipped entirely
1rem is used - the fallback applies
0px - missing attributes default to zero
inherit - falls back to parent value

Question 5

A child element wants to read data-theme from its parent. Can it use attr(data-theme)?

Yes - attr() inherits like custom properties
No - attr() only reads the element's own attributes
Only if the parent has display: contents
Only with the inherit keyword as fallback