loke.dev
Header image for CSS Is Finally Reading Your HTML

CSS Is Finally Reading Your HTML

The new CSS attr() specification is finally breaking free from the 'content' property, allowing you to pass colors, lengths, and numbers directly from HTML attributes into your styles.

· 4 min read

I’ve spent an embarrassing amount of time writing style="--aspect-ratio: 16/9" just to get a component to behave correctly. It’s always felt like a weird hack—using a CSS variable inside an inline style attribute just to pass a simple number from my HTML into my stylesheet.

For years, the CSS attr() function has been the ultimate "tease." It’s existed since the CSS 2.1 days, but it was trapped. You could use it to pull an attribute into the content property of a pseudo-element, and that was basically it. If you wanted to use an HTML attribute to define a width, a color, or a font size? Too bad.

But things are finally changing. The CSS Values and Units Module Level 5 is breaking attr() out of its cage, and it's honestly a bit of a game-changer for how we build components.

The "Old" Way (The tooltips and hacks)

Historically, if you wanted to grab data from HTML, you were stuck doing something like this for a tooltip:

<button data-tooltip="Click me, I'm a button">Hover over me</button>
button::after {
  content: attr(data-tooltip);
  /* This works, but it's purely text-based */
}

If you tried to use attr() anywhere else—like width: attr(data-width)—the browser would just stare at you blankly. It didn't know if that value was a pixel, a percentage, or a potato.

The New Syntax: Telling CSS what’s what

The upgraded attr() allows us to define the type of data we’re pulling. This is the missing link. We can now specify if an attribute should be treated as a color, a length, a number, or even an angle.

The syntax looks like this:
attr(attribute-name <type> [, fallback-value])

Dynamic Spacing without the Inline Style Mess

Let's say you have a card component where you want the padding to be adjustable via the CMS. Instead of clunky inline styles, you can just do this:

<section class="card" data-padding="40px">
  <h2>Flexible Component</h2>
</section>
.card {
  /* We tell CSS: treat 'data-padding' as a <length> unit */
  padding: attr(data-padding length, 20px);
  background: #f4f4f4;
}

If the data-padding attribute is missing, it falls back to 20px. It’s clean, it’s readable, and it keeps your logic in the CSS where it belongs.

Practical Example: A Dynamic Progress Bar

I used to use JavaScript or complex CSS variable injections for things like progress bars. Now, we can let the HTML drive the styling directly.

<div class="progress-container">
  <div class="progress-bar" data-width="75%"></div>
</div>
.progress-bar {
  height: 20px;
  background: #3498db;
  /* Grabbing a percentage value directly */
  width: attr(data-width percentage, 0%);
  transition: width 0.3s ease;
}

The beauty here is that your backend or framework can just spit out a standard HTML attribute, and the CSS handles the heavy lifting. No more recalculating styles or injecting dynamic <style> tags.

Colors are fair game too

This is where it gets fun. You can pass hex codes or color names directly from your markup into your CSS properties.

<div class="badge" data-color="#ff4757">Alert</div>
<div class="badge" data-color="#2ed573">Success</div>
.badge {
  padding: 4px 8px;
  border-radius: 4px;
  color: white;
  /* Tell CSS to parse this as a <color> */
  background-color: attr(data-color color, black);
}

The "Gotchas" (Because there are always gotchas)

Before you go deleting all your CSS variables, we need to talk about reality.

1. Browser Support: This is the big one. While Chrome has started implementing parts of the Level 5 spec (specifically in version 129+), it’s still in the "experimental" phase for many browsers. You shouldn't ship this to a production site that needs to support Safari 14 or older Edge versions just yet.
2. Security: You’re essentially letting the HTML dictate the styling logic. For 99% of cases, this is fine, but if you’re pulling user-generated content into an attribute, you need to make sure you aren't opening yourself up to weird UI injection issues.
3. Types are Strict: If you tell CSS to expect a length (like 20px) but you provide a unitless string (like 20), the browser will likely ignore it or hit the fallback. It doesn't "guess" units for you.

Why this actually matters

I think the real value here isn't just "saving lines of code." It’s about the separation of concerns.

For a long time, if we wanted "dynamic" CSS, we had to rely on CSS-in-JS or inline style attributes. Both feel a bit like we're fighting the platform. By expanding attr(), the W3C is giving us a way to keep our styles in .css files while still allowing the HTML—which often comes from a database or a CMS—to provide the necessary data.

It makes components more portable. It makes our CSS more "aware" of the document structure. And honestly, it’s just much more satisfying to write.

We’re finally getting to a point where the "C" in CSS actually feels like it's communicating with the "H" in HTML, rather than just shouting at it from across the room. Keep an eye on the Can I Use page for this one—it’s going to be a staple in our toolkits very soon.