Up front: I tested the behavior in this post in Chromium 141 and 153. I couldn't find definitive Safari support data while writing, so check caniuse before shipping. If I've gotten something wrong, corrections welcome.

Intro

There's a habit that works really well for accessible components: use ARIA attributes as the hook for your styles.

For example, when an accordion opens, instead of adding a separate .is-open class, write:

[aria-expanded="true"] + .panel {
  display: block;
}

Now "visually open" and "the expanded state a screen reader hears" come from one source. Change aria-expanded and the screen follows; forget to change it and nothing moves on screen either, so you notice right away during development.

But that only works for on/off states. Numbers were out of reach: CSS couldn't turn a progress bar's aria-valuenow="40" into a width, so JavaScript had to update both style.width and aria-valuenow. Two places to change means one can be missed: the bar shows 70% while the screen reader still hears 40%.

attr() fills that gap. It's the "attr() in any property" item in my CSS & HTML cheat sheet.

First, here's what your browser supports:

Your browser's support

  • attr() type()Detecting (requires JavaScript)

1. The old attr(): content only

attr() has been around a long time, but only in content, and it always returned a string:

a::after {
  content: " (" attr(href) ")";
}

Using it for a width, color or spacing did nothing.

2. The new syntax: typed, in any property

Now you can tell the browser what type to treat the attribute as, plus a fallback for when it can't be read:

.bar {
  width: attr(data-width type(<length>), 0px);
  color: attr(data-color type(<color>), black);
}
SyntaxMeaningTested
attr(data-n type(<number>), 0)As a numberdata-n="40" reads as 40
attr(data-w type(<length>), 0px)As a lengthNeeds data-w="50px"; a bare 50 falls back to the default
attr(data-w px, 5px)A number plus a unitdata-w="50" reads as 50px
attr(data-n em, 1em)Other units work toodata-n="5" reads as 5em
attr(data-c type(<color>), black)As a colordata-c="tomato" reads as tomato
attr(aria-level type(<integer>), 1)As an integerGood for levels like aria-level

Most importantly, it can read ARIA attributes.

3. Example 1: a progress bar that reads aria-valuenow

<div class="progress" role="progressbar" aria-label="Upload progress"
     aria-valuemin="0" aria-valuemax="100" aria-valuenow="40">
  <span class="progress__fill"></span>
</div>
.progress {
  --now: attr(aria-valuenow type(<number>), 0);
}
.progress__fill {
  display: block; /* a span is inline by default; make it block so width applies */
  height: 100%;
  width: calc(var(--now) * 1%);
}

JavaScript only has to update one thing:

progress.setAttribute("aria-valuenow", 70);

The width follows automatically. In my tests, changing the value with setAttribute recalculated immediately, with no other code.

The demo below switches between the old and new approaches. Check "Simulate the code forgetting to update aria-valuenow" to see how the old one goes wrong:

Live demo: press "+10" and "โˆ’10" and compare the length on screen with the value a screen reader gets

How it updates
Length on screen
โ€”
What a screen reader gets
aria-valuenow="40"

They match.

Detecting (requires JavaScript)

This is an interactive demo. Try it on the web page.

With the new approach there's simply no chance to "forget": the length on screen is read from aria-valuenow, so if the length is right, what the screen reader gets is right too.

If the minimum isn't 0, read aria-valuemin and aria-valuemax as well:

.progress {
  --now: attr(aria-valuenow type(<number>), 0);
  --min: attr(aria-valuemin type(<number>), 0);
  --max: attr(aria-valuemax type(<number>), 100);
}
.progress__fill {
  width: calc((var(--now) - var(--min)) / (var(--max) - var(--min)) * 100%);
}

In my tests, aria-valuemin="10", aria-valuemax="60" and aria-valuenow="35" gave exactly 50%.

4. The first trap: children can't read a parent's attributes

Notice that --now is set on the outer .progress, not directly on .progress__fill.

That's a trap I hit while testing. My first attempt was (the other styles, such as display: block, are the same as above; only the width line is shown):

/* Doesn't work */
.progress__fill {
  width: calc(attr(aria-valuenow type(<number>), 0) * 1%);
}

The width was always 0, because attr() only reads attributes of the element the declaration applies to. aria-valuenow is on the outer .progress; .progress__fill has no such attribute, so it got the fallback, 0.

The fix is to read the value into a CSS variable on the element that has the attribute, and let children and pseudo-elements inherit it:

.progress { --now: attr(aria-valuenow type(<number>), 0); }  /* read where the attribute lives */
.progress__fill { width: calc(var(--now) * 1%); }             /* children use the inherited variable */

5. Example 2: rating stars

A rating display uses the same pattern. Use role="meter" with aria-valuenow, and have the star length read it:

<span class="rating" role="meter" aria-label="Kyoto rating"
      aria-valuemin="0" aria-valuemax="5" aria-valuenow="4.5"
      aria-valuetext="4.5 out of 5 stars"></span>
.rating {
  --now: attr(aria-valuenow type(<number>), 0);
  position: relative; /* the positioning reference for ::after */
  display: inline-block;
  white-space: nowrap;
}
.rating::before { content: "โ˜…โ˜…โ˜…โ˜…โ˜…" / ""; }
.rating::after {
  content: "โ˜…โ˜…โ˜…โ˜…โ˜…" / "";
  /* sit on top of the grey stars, from the left, and clip the rest */
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  width: calc(var(--now) / 5 * 100%);
  overflow: hidden;
}

Live demo: the star length reads aria-valuenow directly

  • Kyoto
  • Osaka
  • Nara

Detecting (requires JavaScript)

This is an interactive demo. Try it on the web page.

Two accessibility details:

  • The stars are decoration: an empty string after the slash in content stops screen readers from reading out five star characters. The name and value come from aria-label and aria-valuetext.
  • aria-valuetext is the version for people: aria-valuenow is the number for code and CSS, while aria-valuetext is the sentence a screen reader actually says, such as "4.5 out of 5 stars".

6. Example 3: tree indentation from aria-level

Each level of a tree already needs aria-level to tell screen readers how deep it is. The visual indentation can read the same value:

[role="treeitem"] {
  padding-inline-start: calc((attr(aria-level type(<integer>), 1) - 1) * 1.5rem);
}

In my tests, items with aria-level 1, 2 and 4 were indented 0, 24px and 72px. Here attr() is on the treeitem itself, which also has the attribute, so no variable hand-off is needed.

From then on, adjusting the tree means changing aria-level and the indentation follows. Conversely, if the indentation looks wrong, the problem must be in aria-level, which also affects screen reader users, so you catch both at once during development.

7. Other details from testing

  • Invalid values fall back to the default: aria-valuenow="abc" and aria-level="x" both use the default after the comma, so the layout doesn't break. That's why you should always provide a default.
  • It reads attributes, not live element state: drag an <input type="range" value="30"> and input.value changes, but the HTML value attribute is still 30, so attr(value) still reads 30. Live form values still need JavaScript.
  • It can't be used in URLs: background-image: url(attr(data-img)) does nothing, and type(<url>) doesn't read either. This is deliberately blocked for security, so attributes on a page can't be used to load external resources.

8. What happens without support?

Feature detection:

@supports (width: attr(data-x type(<length>))) {
  .progress {
    --now: attr(aria-valuenow type(<number>), 0);
  }
}

Without support, fall back to copying the value into a CSS variable with JavaScript, which is what the demos in this post do:

if (!CSS.supports("width: attr(data-x type(<length>))")) {
  progress.style.setProperty("--now", progress.getAttribute("aria-valuenow"));
}

That way the CSS is written once: it reads the attribute when supported, and the variable written by JavaScript when not.

Conclusion

In a few lines:

  • attr() now takes a type and works in any property, e.g. attr(aria-valuenow type(<number>), 0).
  • Use it to read ARIA attributes so what people see and what screen readers get come from one source: progress bars, ratings, tree indentation.
  • attr() only reads the element's own attributes; for children, read it into a CSS variable where the attribute lives and let it inherit.
  • Always provide a default; it reads attributes, not live state; it can't be used inside url().
  • Without support, copy the value into a CSS variable with JavaScript.

"Let ARIA be the single source of truth" used to work only for on/off states; now it works for numbers too. One less thing to keep in sync is one less thing to go wrong.

Where in your projects is JavaScript updating both styles and ARIA at once? I'd love to hear about it.