Fylgja CSS attr() Polyfill
Compile CSS attr() v2 into static fallback rules for browsers that do not support it.
This is a build time compiler, not a runtime polyfill. It reads your stylesheet, works out which attribute values your project actually uses, and writes the equivalent static CSS.
/* you write this */
[data-py] {
padding-block: calc(var(--spacing) * attr(data-py type(<number>), 1));
}
/* you also get this */
@supports not (padding: attr(x type(<length>), 1px)) {
[data-py] {
padding-block: calc(var(--spacing) * 1);
}
[data-py="2"] {
padding-block: calc(var(--spacing) * 2);
}
[data-py="4"] {
padding-block: calc(var(--spacing) * 4);
}
}
The generated rules sit behind @supports not (...), whose condition is false in browsers
that support attr() v2 and true everywhere else, so only one of the two paths is ever
live. Browsers that do support it keep the original declaration and its unbounded
behaviour.
Installation
npm install @fylgja/css-attr-polyfill
Requires Node 22 or newer.
Usage
CLI
css-attr-polyfill utilities.css -c "src/**/*.{html,jsx,vue}" -o utilities.compiled.css
| Option | Description |
|---|---|
-o, --output <file> |
Write the result here (default: stdout) |
-c, --content <glob> |
Content to scan for attribute values (repeatable) |
-s, --safelist <spec> |
Values for an attribute, as name=spec (repeatable) |
--config <file> |
Load options from a JS or JSON config file |
--split |
Output only the fallback, leaving the source alone |
--supports <cond> |
Override the @supports condition guarding the fallback |
--max-values <n> |
Cap on generated rules per declaration |
--quiet |
Do not print warnings |
With --split, -o receives the fallback stylesheet. There is no second destination,
because the source is returned unchanged and you already have it on disk.
Config file
Everything except the input path and --quiet can live in a config file, so the whole
build reduces to css-attr-polyfill utilities.css --config ./attr.config.json. Keys are
camelCase where the flag is kebab-case.
{
"safelist": { "data-*": "0..12 by 0.5" },
"content": ["src/**/*.html"],
"mode": "split",
"output": "utilities.fallback.css",
"maxValues": 250,
"annotationMode": "merge"
}
A JS config works too, as export default { ... }.
API
import { compile } from "@fylgja/css-attr-polyfill";
const { css, warnings } = await compile(source, {
content: ["src/**/*.{html,jsx,vue}"],
safelist: { "data-*": "0..12 by 0.5" },
});
Use transform() instead of compile() if you already have the values and want a
synchronous, filesystem free call.
Where values come from
A typed attr() is unbounded, so a static stylesheet cannot cover every possible value.
Three sources feed the generator, and their results are combined.
Content scanning. Point content at your markup and the scanner extracts the attribute
values you actually use. It handles HTML, Markdown, JSX, TSX, Vue, Svelte, Astro and server
side templates such as PHP, Twig and Blade. It extracts rather than parses, so one pass
covers all of them.
Safelist. For values scanning cannot see, list them in config. Keys accept * wildcards
and values accept ranges, lists or arrays.
{
safelist: {
"data-*": "0..12 by 0.5",
"anchor": "--tip, --menu",
"data-cols": [1, 2, 3, 4],
}
}
In CSS annotations. Useful when the stylesheet is distributed on its own, since the values travel with it.
/* attr-polyfill: data-py 0..12 by 0.5 */
[data-py] {
padding-block: calc(var(--spacing) * attr(data-py type(<number>), 1));
}
Annotations are merged with config by default. Set annotationMode: "override" to have
them replace it instead.
Output modes
combined (the default) splices each fallback in immediately after its source rule. Every
byte the compiler does not touch is preserved exactly as authored, including your own
formatting and comments.
split leaves the source stylesheet untouched and returns a second stylesheet containing
only the fallbacks, mirroring any @layer, @media or @container nesting. The
@supports guard sits innermost so layer names still register.
Important
The fallback has to come after the rule it replaces. Browsers disagree about what to do
with an unsupported attr(): some drop the declaration, but Safari keeps it, so a
fallback placed earlier would lose to it. In split mode, load the fallback stylesheet
after the source.
Integrations
All three run in combined mode, since a build pipeline expects one stylesheet in and one
stylesheet out. Use the CLI or compile() when you want a separate fallback file.
Vite
import attrPolyfill from "@fylgja/css-attr-polyfill/vite";
export default {
plugins: [attrPolyfill({ content: ["src/**/*.{html,jsx,vue}"] })],
};
PostCSS
import attrPolyfill from "@fylgja/css-attr-polyfill/postcss";
export default {
plugins: [attrPolyfill({ content: ["src/**/*.html"] })],
};
Content is scanned once per build, not once per stylesheet. Warnings surface through the PostCSS result.
Lightning CSS
Lightning CSS parses attr() v2 correctly, but its visitor API models selectors and
declarations as structured values, with no escape hatch for raw CSS text. Generated rules
cannot be injected from a visitor, so this integration runs before Lightning CSS parses the
stylesheet.
import { preprocess } from "@fylgja/css-attr-polyfill/lightningcss";
import { transform } from "lightningcss";
const { code } = await preprocess(source, { content: ["src/**/*.html"] });
transform({ code: Buffer.from(code), filename: "utils.css", minify: true });
What it will not do
Runtime bound attributes. :data-py="n" in Vue, data-py={n} in JSX, or anything set
from JavaScript cannot be read from source. These are detected and reported, and you should
safelist their values.
More than one attr() in a declaration. margin: attr(data-a ...) attr(data-b ...)
needs a cartesian product of both value sets, which the scanner does not have the
co-occurrence data to bound. Such declarations are skipped with a warning.
Behaviour worth knowing
Values are validated against the declared type. data-py="abc" against type(<number>)
produces no rule, because native attr() would resolve to its fallback there too.
Attribute values are always quoted in generated selectors. [data-py=2] is invalid CSS,
since unquoted attribute values must be valid identifiers.
Selectors are narrowed at their subject, never at an ancestor. attr() resolves against
the element the declaration applies to, so .card[data-py] > p generates
.card[data-py] > p:where([data-py="2"]). When the attribute is absent from the selector,
the added match is wrapped in :where() so specificity does not change.
FAQ
Do I still need this once every browser supports attr() v2?
No, and you can drop it without touching your stylesheet. The compiler never rewrites your attr() rules, it only adds guarded copies alongside them, so removing the build step leaves the original CSS working exactly as written.
What happens to a value I never scanned or safelisted?
In browsers with attr() v2 it just works, because the original rule is still there and stays unbounded. Everywhere else it falls back to the value in the attr() fallback argument, which is the same thing a browser would do with an attribute value it could not parse.
Can I put a custom property in the attribute, like data-tint="var(--brand)"?
No. The attribute value has to parse as the declared type on its own, and var() is not a color or a length at parse time. This is true of native attr() too, not a limit of the compiler. Keep the indirection on the CSS side, the way calc(var(--spacing) * attr(data-p ...)) does.
Why does the generated CSS come after the rule it replaces?
Browsers disagree about what to do with an attr() they do not support. Some drop the declaration, but Safari keeps it, so a fallback placed earlier would lose to the very rule it stands in for. In split mode this means the fallback stylesheet has to be loaded after the source.
Editing an attribute value in dev does not regenerate the CSS. Why?
Content is scanned once when a stylesheet is transformed, so changing markup alone does not invalidate it. Touching the stylesheet or restarting the dev server picks the new values up. Production builds are unaffected.
Does this pull PostCSS into my project?
No. CSS documents are read by a parser built into the package. PostCSS, Lightning CSS and Vite are optional peers, needed only by the integration you actually use.