Skip to main content

Harshal V. LADHE

HTML Attributes as Real CSS Values with Typed attr()

Read HTML attributes as CSS colours, lengths and numbers.
Published at:
Last updated:
Estimated reading time:9 min read

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:

TypeReads the attribute as…Example
type(<syntax>)Any CSS data type, parsed from its textattr(data-colour type(<color>))
A unit, like px, %, deg or msA bare number with that unit addedattr(data-size px) turns "24" into 24px
raw-stringA string, exactly as writtenattr(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 typeAttribute 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(&lt;color&gt;))</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(&lt;integer&gt;))</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(&lt;angle&gt;))</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>

Read-only
Ln –, Col –HTML4.4 KBUTF-8

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.
This tutorial is licensed under CC BY 4.0 by the author.

Share this tutorial

Spotted a mistake or something outdated in this tutorial? Suggest a correction.

  • Conditional CSS Values with the if() Function

    Swap CSS values by media, supports, or style conditions.
    The if() function picks between several values inside one CSS declaration based on a media(), supports(), or style() condition — collapsing what used to require duplicating an entire rule under @media/@supports, or a JS class toggle, into one line, with a fallback for browsers that don't support it yet.
    Published at:
  • CSS-Only Staggered Animations with sibling-index() and sibling-count()

    Stagger animations from each element's own position, without JavaScript.
    sibling-index() and sibling-count() give an element its own 1-based position and its sibling total directly inside calc() — replacing the JavaScript loop that sets a --i custom property, with fixed-step and fixed-total staggers, index-driven colours and rotations, fallbacks, and reduced-motion handling.
    Published at:
  • min(), max(), and clamp() — CSS's Value-Bounding Functions

    Three comparison functions that pick a value from a rule, without a single media query.
    min(), max(), and clamp() pick the smallest, largest, or a bounded value from a list of CSS expressions at the point of use, replacing media-query-driven overrides for readable measures, safe-area-aware insets, and bounded widths with one declaration each.
    Published at: