Галоўная / Артыкулы / Typed CSS attr(): Feeding HTML Attribute Values Straight Into Styles

Артыкул апублікаваны на англійскай мове.

Typed CSS attr(): Feeding HTML Attribute Values Straight Into Styles

Learn how typed CSS attr() turns data attributes into lengths, colours and times, how its fallbacks fail, why url() is blocked, and how to ship it safely today.

4056 слоў

Picture a progress bar whose value, 87, is already sitting in the markup as data-fill="87". Until recently, getting that number into width meant a small script: read dataset.fill, add a percent sign, and write an inline custom property back onto the very element the number came from. The value left the document only to be pushed straight back in, at the cost of a script and an extra paint.

Typed attr() removes that detour. The function now accepts a type, so a stylesheet can read an attribute as a length, a colour, a number or an identifier and use it in any property that accepts that type. This guide walks through the syntax, the four ways a lookup can resolve, the patterns that replace common scripts, the security restriction on URLs, and how to ship the feature while browser support is still incomplete.

From string generator to typed value

The classic attr() has worked in every major browser since around 2015, but it had exactly one job: produce a string for the content property, usually on a pseudo-element.

/* the version you already know */
blockquote::after {
  content: " — " attr(cite);
}

That is handy for printing a citation or a link target, and nothing else. Placing the old attr() inside width got the whole declaration thrown out, because the function only returned strings and width has no use for a string.

CSS Values and Units Level 5 changes the contract: you tell the browser what kind of value the attribute contains. Once the engine knows it is dealing with a length, a colour or a number, the result can go anywhere that type is allowed, which in practice means almost every property.

/* the version this article is about */
.bar   { width: attr(data-fill %, 0%); }
.chip  { background-color: attr(data-brand type(<color>), #888); }
.dial  { rotate: attr(data-deg deg, 0deg); }
.card  { view-transition-name: attr(id type(<custom-ident>), none); }

Two clarifications keep expectations realistic, because the feature is easy to oversell in both directions.

  • It is not a way to drop JavaScript from styling. Writing style="--fill: 87%" directly in the HTML has never needed a script. What is new is narrower: using an attribute's value in a style used to require a script, and now it does not.
  • It does not move application logic into CSS. Your application still decides that the task is 87 percent done. The only change is who reads that number from the element: code you wrote, or the stylesheet that was going to be applied anyway.

Anatomy of the function

The grammar fits on one line.

attr( <attr-name> <attr-type>? , <fallback-value>? )

Take attr(data-fill %, 0%). It has three parts: the name of the attribute to read, the type to interpret it as, and a fallback value for when the lookup does not produce something usable.

The type can be written in three different forms. The first is type(), which wraps any CSS data type in angle brackets.

/* 1. type() — any CSS data type */
color:  attr(data-brand type(<color>), crimson);
width:  attr(data-w type(<length>), 0px);
scale:  attr(data-s type(<number>), 1);

The second form is a bare unit, which says "the attribute holds a plain number; attach this unit to it". The third is raw-string, which is also the default when no type is given, and reproduces the historical behaviour.

/* 2. unit shorthand — the attribute holds a bare number */
width:           attr(data-w px);      /* data-w="10"    → 10px  */
rotate:          attr(data-deg deg);   /* data-deg="45"  → 45deg */
animation-delay: attr(data-i s);       /* data-i="2"     → 2s    */
width:           attr(data-fill %);    /* data-fill="87" → 87%   *//* 3. raw-string — the default, and the old behaviour */
content: attr(data-name raw-string, "stranger");
content: attr(data-name);   /* identical */

The unit shorthand expects a unitless number

attr(data-w px) means "read the number in data-w and treat it as pixels". The attribute must therefore be data-w="10". Writing data-w="10px" does not parse, because the unit is supplied by the stylesheet, not the markup. When you want the HTML to carry its own unit (for example because an editor may enter 2rem or 10px), switch to type(<length>) and store the full value.

An invalid fallback fails early and differently

The fallback is parsed together with the rest of the declaration, before the browser ever inspects the element. If you write attr(data-w px, banana), the declaration is invalid at parse time and dropped entirely: the attribute is never read, and any earlier declaration of the same property in the rule applies instead. That is an entirely separate failure from a missing attribute, which is only discovered much later during value computation. Keeping the two apart in your head makes debugging much easier.

The types you can declare

The following types are accepted inside type(), along with what the attribute should contain and typical places to use each.

  • <length>: values such as "12px" or "2rem", for width, padding or inset.
  • <percentage>: "87%", for width or translate.
  • <length-percentage>: either of the previous two, for anything that takes a size.
  • <number>: "4.5", for opacity, scale, or as input to calc().
  • <integer>: "3", for grid-column, z-index or order.
  • <color>: "#4fc3f7" or "tomato", for color and background-color.
  • <angle>: "45deg" or "0.25turn", for rotate or gradient angles.
  • <time>: "300ms", for animation-delay and transition-duration.
  • <resolution>: a density like "2dppx", used by image-resolution.
  • <custom-ident>: an identifier like "card-3", handy for naming things such as view-transition-name or anchor-name.
  • <string>: a quoted CSS string, typically consumed by content.
  • <transform-function>: something like "rotate(20deg)", used with the transform property.
  • <image> and <url>: present in the grammar yet effectively unusable, for reasons covered in the security section below.
  • *: accepts anything, and is mostly handy for feature detection.

Numbers need calc() to become sizes

A <number> is dimensionless. 4.5 cannot set a width on its own, so it nearly always ends up inside calc(). The tidiest approach is to store it in a custom property first and do the arithmetic there.

.stars {
  --r: attr(data-rate type(<number>), 0);
  width: calc(var(--r) * 20%);   /* 4.5 → 90% */
}

The intermediate --r is more than ceremony. It lets you reuse the value across several declarations, and because custom properties inherit, it is also how the value reaches descendants, which, as you will see, is the only way to share an attribute with child elements. If you already use custom properties as the hand-off between scripts and styles, the article on custom properties at runtime covers the other side of this bridge.

How a lookup resolves: four outcomes, not two

It is tempting to assume the feature either works or the rule is ignored. There are actually four distinct results, and confusing them is the most common source of surprises.

  • The attribute is absent. If a fallback is given, it is used. If not, the result is the guaranteed-invalid value and the declaration becomes invalid at computed-value time (IACVT).
  • The attribute exists but is empty (data-x=""). With a typed attr(), an empty string does not parse as the type, so the fallback applies. An untyped lookup, however, counts as a success that yields an empty string, and the fallback is skipped.
  • The value does not parse as the declared type. The fallback is used if present; otherwise, IACVT.
  • The value parses. You get it, and the style updates as soon as the attribute changes.

What "invalid at computed-value time" really means

A regular syntax error is caught while parsing. The browser drops that one declaration, and an earlier declaration of the same property in the same rule takes effect. IACVT happens much later, after the cascade has already chosen a winning declaration, so there is nothing left to fall back to. The property behaves as if set to unset: inherited properties take the parent's value, and non-inherited properties revert to their initial value.

.bar {
  width: 50%;                  /* cascade drops this: the next one parsed fine */
  width: attr(data-fill %);    /* attribute missing → IACVT → width: auto */
}
/* result: auto. NOT 50%. */

This is why always supplying a fallback is not a matter of taste. Without one, a misspelled attribute name quietly yields auto, 0 or an inherited colour, and the console stays silent.

Why an untyped attr() cannot produce a colour

The raw-string default deserves special attention. Suppose a rule sets background-color from an attribute without a type, and you test it on elements that differ only in their attribute. The element without the attribute gets the fallback, as expected. Every element that has the attribute renders transparent, including one containing a perfectly valid colour name.

The explanation is that an untyped attr() substitutes the attribute's literal text as a CSS string, not a parsed CSS value. background-color does not accept a string, so substitution fails at computed-value time. The fallback does not help: it only covers a missing attribute or a parse failure against a declared type, and in this rule nothing was declared. The lookup succeeded at returning a string; it failed one step later at being a colour, where nothing is there to catch it.

/* data-c="red" — and the element still renders transparent */
.swatch { background-color: attr(data-c, gold); }

The takeaway is simple: whenever the target property is not content, declare a type.

Patterns that replace a script

Several things teams routinely do with a few lines of JavaScript can now live entirely in the stylesheet.

A width from a data attribute

The markup carries a plain number.

<div class="bar" data-fill="87"></div>

The stylesheet reads it with the percentage shorthand and falls back to an empty bar.

.bar {
  height: 8px;
  border-radius: 99px;
  background: linear-gradient(90deg, #4fc3f7, #2dd4bf);
  width: attr(data-fill %, 0%);
}

A colour chosen in a CMS

.chip { background-color: attr(data-brand type(<color>), #888); }

A brand colour picked by a content editor is data rather than design. With a typed attr() it can drive the stylesheet directly, without writing inline styles into the template. Since <color> validation happens in the browser, an editor typo simply produces the grey fallback instead of broken CSS.

Staggered list animations

A staggered entrance is usually written by setting an inline style inside a render loop. The index, however, is already part of the markup and does not need to be turned into a style prop.

<li data-i="0">Inbox</li>
<li data-i="1">Drafts</li>
<li data-i="2">Sent</li>

Each item reads its own index as a number, and calc() converts it into a delay.

li {
  --i: attr(data-i type(<number>), 0);
  animation: fade-in .45s both;
  animation-delay: calc(var(--i) * .09s);
}

If the attribute already holds a complete duration, the s unit shorthand can read it directly.

/* or, if the attribute already holds a duration */
li { animation-delay: attr(data-delay s, 0s); }

Routing the index through --i has a side benefit: the timing step becomes a single CSS constant. Change .09s in one place and every staggered list on the site picks up the new rhythm.

A unique view-transition-name per card

This pattern adds something that was not possible before. Each element taking part in a view transition must carry its own unique view-transition-name, which previously meant generating one rule per card or assigning names from a script. A single rule now handles every card.

.card {
  view-transition-name: attr(id type(<custom-ident>), none);
  view-transition-class: card;
}

Note that the attribute here is id, not a data-* attribute. attr() can read any attribute on the element, including id, href, title, colspan or lang. The data-* namespace is simply where HTML expects custom page data to live. Pairing the name with view-transition-class lets you style all cards' transitions with one selector despite each having a unique name.

A useful way to verify any of these patterns is to temporarily remove your script-based baseline and confirm the styling still appears; if it does, attr() is doing the work.

Pseudo-elements: the case with no workaround

Every other benefit in this guide has an alternative: you could always set a custom property inline. Pseudo-elements are different. element.style targets the element itself, and ::before or ::after are not DOM nodes, so there is nothing to attach an inline style to. The previous options were a custom property inherited from the host (which still requires styling the host somehow) or a generated stylesheet. An attribute on the host, by contrast, is readable from both pseudo-elements directly.

Here is a meter whose only data is a single attribute.

<div class="meter" data-pct="72"></div>

The label uses the classic string attr() in content, which works in every browser.

.meter::before {
  content: attr(data-pct) "% complete";
}

The bar uses the typed form to set its width from the same attribute.

.meter::after {
  content: "";
  display: block;
  height: 10px;
  width: attr(data-pct %, 0%);
  background: linear-gradient(90deg, #fbbf24, #f472b6);
}

One attribute feeds two pseudo-elements, and neither requires an extra child element. Count badges, progress rings, tooltip text, ordinal prefixes, unit suffixes and chart data labels all follow this shape: a pseudo-element that needs a number the host already carries. Before typed attr(), that number had to be written twice, once as an attribute for the text and once as a custom property for the geometry, and the two copies could drift apart. Now it is written once.

attr() only reads the element being styled

The function reads attributes from the element the rule matches. It does not look at ancestors or siblings, and there is no equivalent of closest(). Because the fallback kicks in, this mistake fails silently, which is why nearly everyone makes it once.

Consider a section that declares an accent colour for its contents.

<section class="theme" data-accent="#4fc3f7">
  <h2>Title</h2>
</section>

Reading the attribute from the heading does nothing useful, because the heading does not have it.

/* WRONG — this reads the h2, which has no data-accent */
.theme h2 { color: attr(data-accent type(<color>), #888); }
/* every heading is #888, forever, with no error */

The fix is to read the attribute once, on the element that owns it, and hand the value down through a custom property.

/* RIGHT — read it once on the element that has it, then inherit */
.theme    { --accent: attr(data-accent type(<color>), #888); }
.theme h2 { color: var(--accent); }
.theme a  { border-bottom: 2px solid var(--accent); }

Inheritance does the distribution. As a bonus, any subtree can now override the accent with an ordinary --accent declaration, something the direct attr() approach could never support.

Why URLs are off limits

Every value produced by attr() is marked as attr()-tainted, and a tainted value may never be used to build a URL, by any path. Both of these declarations have no effect:

span[data-icon] { background-image: url(attr(data-icon)); }
span[data-icon] { background: image-set(attr(data-icon)); }

The taint travels with the value. Routing it via a custom property, wrapping it in src() or image(), or nesting it inside other functions to any depth leaves the mark in place. Wherever it ends up in a URL context, the declaration becomes invalid at computed-value time.

The threat model explains the rule. Attribute values are often influenced by users: display names, CMS slugs, comment titles. If a stylesheet could turn such a value into a network request, anyone able to write into that field could make every visitor's browser fetch an address of their choosing, along with whatever the browser normally attaches to such requests. Resolving this in the specification is what allowed browser vendors to agree on shipping the feature. The WebKit announcement of Interop 2026 makes this link explicit: with the security questions settled in the specification, all the engines said they were ready to ship the capability and to do so interoperably.

Keep the limits of that protection in mind. Anything in an attribute is readable by every script on the page, visible in view-source, and delivered in the HTML payload. The URL ban closes one exfiltration route; it does not make attributes private. Never put secrets in them.

Detecting support and shipping progressively

The safe approach is a plain baseline that every browser understands, with the typed version layered on top for engines that support it. Start with the baseline, here driven by an inline custom property.

.bar {
  width: var(--fill, 0%);          /* baseline, everyone */
}

Then let supporting browsers switch to the attribute.

@supports (width: attr(x %)) {
  .bar {
    width: attr(data-fill %, 0%);  /* modern browsers take over */
  }
}

The same test is available at runtime if your code needs to branch.

// the same answer at runtime, if you need to branch in code
if (CSS.supports("width", "attr(x %)")) {
  // typed attr() is available
}

Test against a real property

Be careful with detection snippets that use a placeholder property name, such as @supports (x: attr(x type(*))), which has appeared in reference documentation. No CSS property called x exists, and every browser answers false when a support query mentions a property it does not know, so the test fails even in browsers where the feature works. Use a property that exists. The query @supports (width: attr(x %)) is reliable: width is a genuine property, and attr(x %) can only be parsed by engines that implement the unit shorthand. It is worth checking the current MDN reference for attr(), since documentation examples do get corrected.

Relying on the cascade instead of @supports

A shorter variant skips the @supports block and lets the cascade decide.

.bar {
  width: var(--fill, 0%);        /* everyone */
  width: attr(data-fill %, 0%);  /* supporting browsers override */
}

A browser that cannot parse attr() inside width drops that declaration at parse time, so the earlier line applies. That behaviour follows from how the cascade handles invalid declarations. Still, the @supports form states the intent explicitly and costs only a couple of extra lines, so prefer it if you want the behaviour to be obvious to future readers, and verify the shorter form in a non-supporting engine before relying on it.

In both variants, keep a fallback inside attr() itself. In a supporting browser, a missing attribute does not fall back to the var() declaration above; the attr() declaration has already won the cascade, so it becomes IACVT and the width resolves to auto.

Browser support at the time of writing

Support is changing quickly, so treat these figures as a snapshot and confirm them on Can I use before relying on them. As of August 2026, roughly 70 percent of global users had a browser with typed attr():

  • Chrome 133 and later: supported, and the first to ship it (see the Chrome for Developers announcement).
  • Edge 133 and later: supported, sharing Chromium's release.
  • Firefox 155 and later: supported; present but disabled by default in versions 152 to 154.
  • Samsung Internet 29 and later: supported.
  • Safari on desktop: not yet shipped; available only in Technology Preview.
  • Safari on iOS: not yet shipped, and since every iOS browser uses WebKit, that covers all iOS browsers.

The legacy string-only attr() inside content is a separate matter. It has been Baseline widely available for a decade, all the way back to very early versions of every major engine. If all you need is content: attr(data-label), you can use it without any of the precautions above.

Interop 2026 lists typed attr() among its focus areas. Interop is the annual collaboration in which Apple, Google, Microsoft and Mozilla, together with Igalia and Bocoup, select features that are missing or broken in at least one engine and commit to improving them over the year. Progress is tracked on a public dashboard based on the shared Web Platform Tests. That is the strongest public signal that Safari support is being built, but it is a commitment, not a release date. Plan on a fallback for the rest of 2026. For a broader approach to shipping new CSS behind safe baselines, see shipping modern CSS safely.

Choosing between attr() and JavaScript

Neither tool wins everywhere. The split below is a practical guide.

Prefer attr() when:

  • The markup already holds the value. Pulling it into a script just to set it again on the same node gains you nothing.
  • It has to be right on the first frame. The attribute exists as soon as the parser creates the element, whereas a script runs later and may cause a visible correction.
  • A pseudo-element needs the value. Inline styles set through element.style have no way to target ::before or ::after.
  • You run a strict Content Security Policy. Inline styles require unsafe-inline or a nonce; data attributes require neither.
  • Elements are added dynamically. Freshly inserted nodes pick up their styling from rules that already exist; nothing has to run again, and you need no MutationObserver watching the tree.
  • You want a single source of truth. The scripted approach often keeps the value in both an attribute and an inline style, and the two can diverge.
  • You want presentation to stay in CSS, visible to the cascade, to media and container queries, and to the browser's style inspector.

Stay with JavaScript when:

  • The value has no reason to exist in the DOM. A measurement from a ResizeObserver or a scroll handler should not be turned into an attribute first.
  • It updates every animation frame. Calling el.style.setProperty('--x', v) is more direct and skips writing an attribute.
  • The payload is big, nested or confidential. Anything in an attribute is a publicly visible string.
  • You need Safari today and the value matters. A progress bar stuck at zero in one browser is a defect, not a graceful fallback.

Performance is conspicuously absent from the first list. Setting one inline style was never a bottleneck. What you gain is a better home for the value and better visibility into it, not speed.

Pitfall checklist

  • Always provide a fallback. A missing or unparseable value without one yields IACVT and an unset property, not the previous declaration.
  • An empty attribute is not a missing one. Untyped, data-x="" returns an empty string and the fallback never fires.
  • Untyped means literal text. background-color: attr(data-c, gold) with data-c="red" renders nothing; add a type.
  • No url() and no image-set(). The taint is deliberate and survives custom properties.
  • Only the matched element is read. For ancestors, store the value in a custom property and let it inherit.
  • Unit shorthands need bare numbers. Use data-w="10", not data-w="10px".
  • An invalid fallback kills the declaration at parse time, which is an earlier and different failure from a missing attribute.
  • A <number> cannot size anything by itself. Store it and multiply in calc().
  • Attributes are public. Keep secrets out of them.
  • Feature-test with a real property, such as @supports (width: attr(x %)).
  • Safari has not shipped it yet at the time of writing, so keep a baseline for anything essential.

Wrapping up

Typed attr() is a small syntax change with a clear effect on architecture: values that already live in HTML no longer need a script to become styles. The feature rewards precision. Declare the type, always write a fallback, read the attribute on the element that owns it and pass it down through custom properties, and accept that URLs are permanently off limits. Pair every load-bearing use with a baseline until Safari ships, and the pattern can go into production today, with the stylesheet taking over automatically as support spreads. The formal definition lives in the CSS Values and Units Level 5 draft if you need the exact parsing rules.