CSS Gets a Class Prefix Selector, and It's 20x Faster Than Attribute Selectors

Two weeks ago, at the CSS Working Group's in-person meeting in Berlin, the group resolved to add a new selector to the Selectors Level 5 spec: the class prefix selector. The syntax is one you'd write in your sleep.

CSS Gets a Class Prefix Selector, and It's 20x Faster Than Attribute Selectors

CSS Gets a Class Prefix Selector, and It's 20x Faster Than Attribute Selectors

Two weeks ago, at the CSS Working Group's in-person meeting in Berlin, the group resolved to add a new selector to the Selectors Level 5 spec: the class prefix selector. The syntax is one you'd write in your sleep.

.btn-* {
  padding: 0.5rem 1rem;
  border-radius: 4px;
}

That trailing -* makes it a prefix match. It styles every class that starts with btn-: .btn-primary, .btn-secondary, .btn-danger, all of them, in one rule. No base class. No comma-separated list. No attribute selector.

The problem it actually solves

If you build design systems, you already know the pain. You have a family of prefixed classes and you want one base style to apply to all of them. Today you have two options, and neither is great.

The first is to add a shared base class to the markup:

<button class="btn btn-primary">Save</button>

Now your HTML carries a redundant btn token on every single button, and you have to remember to keep it in sync. The second is to list them all out, which is fragile and gets ugly fast:

.btn-primary,
.btn-secondary,
.btn-danger {
  padding: 0.5rem 1rem;
}

And then there's the workaround that a lot of people reach for, the substring attribute selector:

[class^="btn-"],
[class*=" btn-"] {
  padding: 0.5rem 1rem;
}

It works. It also runs slow. Bramus, who championed the feature, benchmarked it with css-selector-benchmark and got a plain class selector at over 6,000 matches per second against about 328 for [class*=" btn-"]. That's roughly 20 times slower, and it burns about 3ms of your frame budget just to match a single element. On a list of buttons, that adds up in a way you can feel.

Why the hyphen, and not a wildcard

The obvious question is why .foo-* and not .foo*. The answer is that an unseparated wildcard would overselect. .foo* would match .footer, which is almost never what you want. More importantly, it would wreck the performance win. Browsers build buckets keyed on class names while parsing the HTML, before the CSS even runs. A fixed hyphen-separated prefix lets them keep doing that. A free-floating wildcard throws the whole optimization away.

The group also decided against reusing the existing dash-match attribute selector, [class|="foo"]. It was pitched on the call, and it looked tempting at first. But |= was designed for language attributes like [lang|="en"], where it matches both en and en-us. For utility classes that's a footgun: you don't want your .bi-* icon styles leaking onto a standalone .bi base class. And |= only checks the very start of the class attribute string, so it would miss btn-primary on an element that also carries another class first. The -* syntax is cleaner, and it sets up a broader wildcard effort for attribute names and element names down the line.

What it doesn't match

A few edge cases are worth knowing while the spec is still settling. .foo-* does not match an element whose class is exactly foo-. It needs at least one character after the prefix, and that character can't itself be a hyphen. So class="foo-" and class="foo--" both slip through. The group is still ironing out the exact default, so expect this to shift before it lands in a browser.

Non-hyphen separators like underscores aren't in scope for now. The group wants to see what authors actually need before widening it.

When you can use it

Nowhere. Yet. It was resolved two weeks ago, it lives in spec text only, and there's no browser behind it. Chromium has a tracking bug, but Firefox and Safari don't have one filed at all. Bramus is blunt that it could take a few years before this is production-safe, and I'd believe him. The spec will almost certainly change before any engine implements it.

That doesn't make it useless to know about. You can already feature-detect it the normal way:

@supports selector(.foo-*) {
  /* the browser has the class prefix selector */
}

So the practical move today is to keep writing the base class or the explicit list, and to watch this one. It's a small, well-motivated addition that removes a real, everyday annoyance, and the performance story is the part that makes it more than a syntax nicety. The 20x number is the real reason it cleared the room in Berlin.

Comments