HTML Attributes as Real CSS Values with Typed attr()
Introduction
CSS has had attr() for decades, but only in one place. Inside the content property of a ::before or ::after
pseudo-element, it reads an HTML attribute and inserts it as text — which is how
CSS-Only Tooltips with attr() and content turn a data-tooltip attribute
into a tooltip with no JavaScript:
.has-tooltip::after {
content: attr(data-tooltip);
}Anywhere else, attr() did nothing. width: attr(data-width) was invalid, because the classic function always
returns a string, and a string isn't a width. So data that already lived in the HTML — a progress percentage, a
column count, a brand colour from a CMS — had to be copied into inline styles or custom properties before CSS could
use it.
Typed attr() removes that limit. Tell it what type to read the attribute as, and it returns a real CSS value —
a length, a colour, a number, an angle — usable in any property, on any element.
Classic vs Typed
<div class="meter" data-progress="65"></div>/* Classic: a string, only useful as text */
.meter::after {
content: attr(data-progress) "%";
}
/* Typed: a real percentage, usable as a width */
.meter::before {
inline-size: attr(data-progress %, 0%);
}The attribute is the same "65" in both. The classic form inserts it as the characters "6" and "5"; the typed form,
told to read it with the % unit, turns it into 65% — a value inline-size understands.
The Syntax
attr(<attribute-name> <type>?, <fallback>?)The type is what's new. It can be one of three things:
| Type | Reads the attribute as… | Example |
|---|---|---|
type(<syntax>) | Any CSS data type, parsed from its text | attr(data-colour type(<color>)) |
A unit, like px, %, deg or ms | A bare number with that unit added | attr(data-size px) turns "24" into 24px |
raw-string | A string, exactly as written | attr(data-label raw-string) |
With no type at all, attr() behaves exactly as it always has and returns a string, so existing content: attr(…)
code keeps working unchanged.
Data Types for type()
type() accepts the same data types used to
register custom properties with @property:
| Data type | Attribute value examples |
|---|---|
<color> | "#2dd4bf", "rebeccapurple", "oklch(0.75 0.15 60)" |
<length> | "2rem", "24px" |
<percentage> | "65%" |
<length-percentage> | "2rem" or "50%" |
<number>, <integer> | "1.5", "3" |
<angle> | "45deg", "0.25turn" |
<time> | "200ms", "1.5s" |
<custom-ident> | "card-42" |
Alternatives can be combined with |, as in type(<length> | auto).
Unit or type()?
They differ in where the unit lives. With type(<angle>), the attribute must include it: data-angle="45deg".
With the deg unit, the attribute is a bare number and CSS adds the unit: data-angle="45". Use a unit when the
HTML comes from somewhere that only deals in numbers, like a database or a range input's value.
Fallbacks
The second argument is used whenever the attribute is missing, or its value doesn't parse as the requested type:
.tag {
--tag: attr(data-colour type(<color>), indigo);
color: var(--tag);
border: 1px solid var(--tag);
}data-colour="teal" gives a teal tag; data-colour="not-a-colour", or no attribute at all, gives an indigo one.
Without a fallback, an invalid or missing attribute makes the whole declaration invalid at computed-value time — the
same as an undefined var() — and the property falls back to its inherited or initial value. Give every typed
attr() a fallback unless that's what you want.
Try It Live
The four components in the sandbox are styled entirely from their own attributes: a progress bar from
data-progress read as a percentage, a tag from data-colour read as a <color>, a grid from data-columns read as
an <integer>, and a dial from data-angle read as an <angle>. The controls only change HTML attributes — the
readout shows the current markup — and the CSS reacts. Choose not-a-colour to see the tag fall back to indigo.
The percentage label on the bar is the classic attr() in content, working side by side with the typed one.
Typed attr() Playground
A progress bar, colour tag, grid and dial styled entirely from their own HTML attributes with typed attr() — read as a percentage, <color>, <integer> and <angle> — where the controls only edit attributes and an invalid colour shows the fallback.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Typed attr() Playground</title> </head> <body> <main class="playground"> <header class="playground-header"> <h1>HTML Attributes as CSS Values with Typed attr()</h1> <p>The controls only change HTML attributes. Typed <code>attr()</code> reads each one as a percentage, colour, integer or angle, and CSS does the rest — no inline styles, no custom properties set from JavaScript.</p> </header> <p class="support-note" id="support-note" hidden>This browser only supports the classic <code>attr()</code> inside <code>content</code>, so every component shows its default: an empty bar, an indigo tag, two columns and an upright needle. The percentage label still updates, because that part uses the classic form.</p> <section class="demo-area components" aria-label="Attribute-driven components"> <div class="component"> <p class="component-caption"><code>attr(data-progress %)</code></p> <div class="meter" id="meter" data-progress="65" role="img" aria-label="65% complete"></div> </div> <div class="component"> <p class="component-caption"><code>attr(data-colour type(<color>))</code></p> <span class="tag" id="tag" data-colour="#2dd4bf">Design system</span> </div> <div class="component"> <p class="component-caption"><code>attr(data-columns type(<integer>))</code></p> <ul class="gallery" id="gallery" data-columns="3"> <li>1</li><li>2</li><li>3</li><li>4</li><li>5</li><li>6</li> </ul> </div> <div class="component"> <p class="component-caption"><code>attr(data-angle type(<angle>))</code></p> <div class="dial" id="dial" data-angle="45deg" aria-hidden="true"><span class="dial-needle"></span></div> </div> </section> <fieldset class="control-group"> <legend>Attributes</legend> <div class="fields-grid"> <div class="field"> <label for="progress-input">data-progress</label> <div class="range-row"> <span class="range-edge">0</span> <div class="range-track"> <input type="range" id="progress-input" min="0" max="100" step="1" value="65"> <output class="range-bubble" id="progress-value" for="progress-input">65</output> </div> <span class="range-edge">100</span> </div> </div> <div class="field"> <label for="columns-input">data-columns</label> <div class="range-row"> <span class="range-edge">1</span> <div class="range-track"> <input type="range" id="columns-input" min="1" max="6" step="1" value="3"> <output class="range-bubble" id="columns-value" for="columns-input">3</output> </div> <span class="range-edge">6</span> </div> </div> <div class="field"> <label for="angle-input">data-angle</label> <div class="range-row"> <span class="range-edge">0deg</span> <div class="range-track"> <input type="range" id="angle-input" min="0" max="360" step="5" value="45"> <output class="range-bubble" id="angle-value" for="angle-input">45deg</output> </div> <span class="range-edge">360deg</span> </div> </div> </div> <span class="field-label" id="colour-label">data-colour</span> <div class="segmented-control" role="radiogroup" aria-labelledby="colour-label"> <label class="segment"><input type="radio" name="colour" value="#2dd4bf" checked><span>#2dd4bf</span></label> <label class="segment"><input type="radio" name="colour" value="rebeccapurple"><span>rebeccapurple</span></label> <label class="segment"><input type="radio" name="colour" value="oklch(0.75 0.15 60)"><span>oklch(…)</span></label> <label class="segment"><input type="radio" name="colour" value="not-a-colour"><span>not-a-colour</span></label> </div> <p class="tip" id="colour-tip"></p> </fieldset> <output class="readout" id="readout" aria-live="polite"></output> </main> <script src="./index.js"></script> </body> </html>
Starting sandbox…
No console output yet.
Patterns Worth Knowing
Passing a Value Down to Children
attr() reads the attribute of the element being styled — or, for a pseudo-element, the element it belongs to. To
use an attribute on a child, read it on the parent into a custom property, and let the child inherit it:
.dial {
--angle: attr(data-angle type(<angle>), 0deg);
}
.dial-needle {
rotate: var(--angle);
}The attribute is resolved on .dial, so the needle inherits the finished 45deg, not the instruction to read an
attribute it doesn't have.
Layout from Content Data
.gallery {
display: grid;
grid-template-columns: repeat(attr(data-columns type(<integer>), 2), 1fr);
}A CMS or component prop can set data-columns="4" on the markup, with no class per column count and no inline
style.
Unique Names for View Transitions
Every element in a view transition needs a unique view-transition-name. Most list items already have a unique
id, and <custom-ident> turns it into a name:
.card {
view-transition-name: attr(id type(<custom-ident>), none);
}One rule names every card, instead of one generated inline style per item. Two details are worth knowing. An id
that isn't a valid CSS identifier — id="42" starts with a digit — doesn't parse as a <custom-ident>, so that
card gets the none fallback and simply isn't animated on its own. And if the names only need to be unique, not
predictable, Chromium's view-transition-name: match-element does the same job without any attribute. Reach for
attr(id …) when the name has to match across pages, as in a cross-document view transition, where the browser can
only pair old and new elements that share a name.
Branching on an Attribute with if()
A <custom-ident> read into a custom property is a value that
if() and style() can test, so a single attribute can pick between
several values without a class for each:
.card {
--priority: attr(data-priority type(<custom-ident>), low);
background: if(
style(--priority: high): #fee2e2;
style(--priority: medium): #fef3c7;
else: #f1f5f9
);
}<div class="card" data-priority="high"> turns the card red, with no inline style and no JavaScript.
Staggering in a Custom Order
sibling-index() staggers animations by an element's position in the
DOM. When the order should come from data instead — a rank, a priority, the order results arrived in — store it in
an attribute:
.result {
animation: fade-in 0.3s ease both;
animation-delay: calc(attr(data-order type(<integer>), 0) * 80ms);
}Transitions Come for Free
A typed attr() produces an ordinary computed value, so when the attribute changes, any transition on that
property runs, just as it would after a class change:
.meter::before {
inline-size: attr(data-progress %, 0%);
transition: inline-size 0.2s ease;
}Updating data-progress from JavaScript (or a framework re-render) animates the bar to its new width. The progress
bar in the sandbox works this way.
attr() or a Custom Property?
The same progress bar can be written with an inline custom property, which every modern browser supports today:
<div class="meter" style="--progress: 65%"></div>Both work. Reach for typed attr() when the value is already in the HTML for another reason — a data-*
attribute read by JavaScript too, or a native one such as colspan — so it doesn't need repeating in a style
attribute. It
also keeps styling out of the markup entirely, which helps under a Content Security Policy that blocks inline styles.
Reach for a custom property when the value only exists for styling, or when the component must work in every
browser right now.
Browser Support and Fallbacks
Typed attr() shipped in Chromium browsers (Chrome and Edge 133) in 2025. Firefox and Safari don't support it yet
at the time of writing; there, only the classic content: attr(…) form works.
In a browser without support, a declaration using typed attr() is invalid and dropped, so the rule above it in the
cascade wins. Declare a sensible default first, then the attr() version:
.gallery {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(attr(data-columns type(<integer>), 3), 1fr);
}When a whole block of styles depends on it, test for support with @supports:
@supports (width: attr(data-x type(<length>), 0px)) {
/* typed attr() styles */
}The Custom Property Trap
The "default first" trick doesn't work when attr() is stored in a custom property, as in the tag and dial
examples. An unregistered custom property accepts any value at all, so an unsupporting browser keeps
--tag: attr(data-colour type(<color>), indigo) as plain text instead of dropping it. Every var(--tag) then
becomes invalid at computed-value time, the property resets to its inherited or initial value, and the default
declared before it is ignored.
Two fixes work. Wrap the custom property in the @supports test above, or register the property with @property
so that it has a type and an initial value:
@property --tag {
syntax: "<color>";
inherits: false;
initial-value: indigo;
}
.tag {
--tag: attr(data-colour type(<color>), indigo);
color: var(--tag);
}A registered <color> property can't hold the text of an unsupported function, so browsers without typed attr()
throw that value away and use indigo, the initial-value. For everything else registration changes, see
Animate and Type-Check CSS Variables with @property.
Common Pitfalls
1. Leaving Out the Type
inline-size: attr(data-progress) is still a string, and still invalid outside content. Typed behaviour only
starts when you name a type or a unit.
2. Putting the Unit in the Wrong Place
data-angle="45" with type(<angle>) fails, because 45 isn't an angle — and data-angle="45deg" with the deg
unit fails, because 45deg isn't a bare number. Match the attribute's format to the form of attr() you chose.
3. Expecting It to Build URLs
background-image: url(attr(data-src)) doesn't work, and won't. Values from attr() can't be used to make URLs, so
an attribute an attacker can control can't be turned into a network request by your CSS. Keep image URLs in src
attributes or in your stylesheet.
4. Reading an Attribute from the Wrong Element
attr() only ever looks at the element being styled. A child can't read its parent's attribute directly; resolve it
on the parent into a custom property, as in the dial pattern above.
5. Expecting It to Track Live Form Values
attr() reads the HTML attribute, not the JavaScript property behind it. When someone drags a range slider or
types into a field, the element's value property changes but its value attribute keeps the value it started
with, so attr(value …) doesn't move. If CSS needs to follow user input, copy the value into a data-* attribute
(or a custom property) in an input event listener, as the sandbox does.
6. Using It as the Only Source of Meaning
A bar whose width comes from data-progress looks like a progress indicator, but assistive technology sees only a
<div>. Styling doesn't carry meaning, so pair the visual with a native <progress> or <meter>, or with ARIA —
the sandbox's bar has role="img" and an aria-label that's updated alongside the attribute.
Key Takeaway
Classic attr() could only print an attribute as text. Typed attr() — attr(data-colour type(<color>), indigo),
attr(data-progress %, 0%) — reads it as a real CSS value for any property on any element, with a fallback for
missing or invalid values. Use it when the data already lives in your HTML, give it a fallback, declare a default
before it for browsers without support — or register the property when attr() lives in a custom property — and
keep using the classic form for text in content.
Changelog
- — Added cross-reference to tutorial 'Animate and Type-Check CSS Variables with `@property`'
- — Initial publication.
Share this tutorial
Spotted a mistake or something outdated in this tutorial? Suggest a correction.