npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@fylgja/css-attr-polyfill

v1.0.0

Published

Compile CSS attr() v2 into static fallbacks for browsers that do not support it

Readme

Fylgja - CSS attr polyfill

NPM version License

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);
    }
}

Installation

npm install @fylgja/css-attr-polyfill

Requires Node 22 or newer.

Dependencies

The core depends on two small string level parsers and nothing else. Despite their names, neither is PostCSS, and postcss-value-parser has no dependencies of its own.

| Package | Used for | | ------------------------- | ---------------------------- | | postcss-value-parser | Reading the attr() grammar | | postcss-selector-parser | Narrowing selectors safely |

CSS documents are read by a parser built into this package, so nothing pulls in a CSS framework. postcss, lightningcss and vite are optional peers, needed only by the integration you actually use.

Why it works

The generated rules are wrapped in @supports not (...), whose condition is false in browsers that support attr() v2 and true everywhere else. Only one of the two paths is ever live, so they cannot fight.

Within the fallback path, the generated rules are emitted after the attr() rule they stand in for. Browsers vary in how they treat an unsupported attr(): some drop the declaration outright, but Safari keeps it. A fallback placed earlier would lose to the very rule it replaces.

Modern browsers keep the original attr() declaration untouched, so values outside the generated set still work there.

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",
  "supports": "not (padding: attr(x type(<length>), 1px))",
  "maxValues": 250,
  "annotationMode": "merge",
  "cwd": "."
}

| Key | Flag | Notes | | ---------------- | ----------------- | ------------------------------------------------ | | safelist | -s | Flags merge on top of the config, per attribute | | content | -c | Flags replace the config outright | | mode | --split | "combined" (default) or "split" | | output | -o | | | supports | --supports | | | maxValues | --max-values | | | annotationMode | none | "merge" (default) or "override" | | cwd | none | Base directory for content globs |

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. It has to come after, not before: browsers without attr() v2 do not reliably drop the declaration at parse time. Safari keeps it, so a fallback placed earlier would lose to the very rule it stands in for. Staying adjacent keeps the source rule's position relative to everything else in the stylesheet. 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. Load the fallback stylesheet after the source, for the same reason combined mode places it after.

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.

Values outside the generated set. In browsers without attr() v2 these fall back to the value in the attr() fallback argument, exactly as an invalid attribute value would natively.

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.

Try it

example/ is a Vite setup showing how you would ship this in practice: one stylesheet, one page, both paths in the built CSS. Open it in different browsers and a badge tells you which path that browser took. It should look the same either way.

It is styled with @fylgja/base and @fylgja/tokens. Neither is required by this package, which works with any CSS.

cd example && npm install && npm run dev

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}"] })],
};

Deliberately not a pre plugin. Vite inlines @import inside its own CSS plugin, so a pre plugin would only see the entry stylesheet and silently generate nothing. Running afterwards means it sees the CSS that actually ships, whether you assemble it with @import or with JavaScript imports.

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 as SelectorComponent[] and declarations as structured values, with no escape hatch for raw CSS text. Generated rules therefore 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 });