Skip to content

Improve style invalidation performance of group-* and peer-* variants - #20513

Merged
RobinMalfait merged 3 commits into
mainfrom
fix/improve-group-and-peer-performance
Sep 25, 2026
Merged

RobinMalfait merged 3 commits into
mainfrom
fix/improve-group-and-peer-performance

Conversation

@RobinMalfait

@RobinMalfait RobinMalfait commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

For humans, by @RobinMalfait

TL;DR

Some people ran into performance issues with the current group-* variant because of the * inside of the selector. Take the group-focus-visible:flex class for example, this generates:

.group-focus-visible\:flex:is(:where(.group):focus-visible *) {
  display: flex;
}

This slightly rewrites the selector by maintaining the same functionality, but increasing performance because the browser has to do fewer style recalculations.

That same class now produces:

:is(:where(.group):focus-visible .group-focus-visible\\:flex) {
  display: flex;
}

For AI, generated by AI

This PR changes the selectors generated for group-* and peer-* variants so browsers do less style recalculation when a group or peer changes state (e.g. on focus or hover). Which elements match, and with what specificity, stays the same, except for one deliberately accepted edge case involving @namespace (see below).

/* Before */
.group-focus\:flex:is(:where(.group):focus *) { display: flex; }
.peer-focus\:flex:is(:where(.peer):focus ~ *) { display: flex; }

/* After */
:is(:where(.group):focus .group-focus\:flex) { display: flex; }
:is(:where(.peer):focus ~ .peer-focus\:flex) { display: flex; }

Why

In the old form, the subject inside :is(…) is *. When .group changes state, Chromium has to recalculate styles for every descendant of the group (or every following sibling of the peer), not just the elements that use the utility. With the target itself in that position, the browser can narrow invalidation down to elements matching the target.

Performance

Synthetic benchmark: toggle focus on a group/peer, flush style after each change, and measure only a non-layout property (outline-color) to isolate invalidation. Apple M5 Pro, macOS arm64.

Elements recalculated per focus/blur (Chromium 151, UpdateLayoutTree elementCount, including the focused element):

Scenario Before After
Group: 100 targets among 10,000 descendants 10,001 101
Peer: 20 targets among 2,000 siblings 2,001 21

Median time per focus/blur (sparse: 1% of elements carry the utility):

Engine Group: before → after Peer: before → after
Chromium 151 2.169 → 0.130 ms (~17×) 0.635 → 0.192 ms (~3.3×)
Firefox 153 0.500 → 0.350 ms 0.650 → 0.600 ms
WebKit 26.5 0.750 → 0.750 ms 0.450 → 0.450 ms

Dense case (every element carries the utility): no meaningful difference in any engine, because every element needs recalculation anyway. Firefox's dense group case was ~5% slower (2.775 → 2.925 ms). Everything else was within noise.

Engine Group dense: before → after Peer dense: before → after
Chromium 151 4.027 → 3.971 ms 18.095 → 18.121 ms
Firefox 153 2.775 → 2.925 ms 43.900 → 44.025 ms
WebKit 26.5 7.550 → 7.425 ms 35.325 → 35.100 ms

These are micro-benchmarks of style updates, not page-load or frame-rate numbers. The real-world gain depends on DOM size and how many elements inside a group/peer use the variant. The biggest win is the common case: a large group containing only a handful of group-* targets.

Do the selectors behave the same?

Yes. For a group condition G and a target &:

  • Before: matches & and has an ancestor matching G
  • After: has an ancestor matching G and matches &

peer-* follows the same reasoning with ~. Details:

  • Specificity is unchanged. :is() takes the specificity of its argument, so before was spec(&) + spec(G) and after is spec(G) + spec(&).
  • & is used, not the utility class, so @apply, @variant, *:group-*, [&_p]:group-*, and other variants that change the target keep working. Complex parents such as .foo .bar { @apply peer-focus:flex } keep :is(…) semantics during nesting: :is(P ~ :is(.foo .bar)).
  • & appears only once, so stacked variants grow the selector linearly. A unit test with 12 stacked variants guards against exponential growth.
  • The selectors are also shorter: 6 bytes of wrapping instead of 7.
  • The outer :is(…) keeps compound variants such as has-group-*, not-group-*, and in-group-* equivalent. For example, has-group-* can still match when the group sits outside the element carrying the utility.

Accepted edge case: if a stylesheet declares a default @namespace, the old trailing * limited matches to elements in that namespace. Inside compound variants such as group-group-* or has-group-*, the new selector no longer does, so an SVG element (e.g. inside foreignObject) can now count as the inner group. Appending :is(*) to the target would restore the old behavior with no performance cost, but it makes every selector longer for a combination (@namespace + mixed namespaces + compound group variants) that is very unlikely in practice. We can add it back if anyone runs into this.

There's one known browser quirk this PR doesn't change: Chromium doesn't invalidate has-group-* when the focused group is an ancestor outside the element. That happens with both the old and new selectors.

Test plan

  • Updated unit test snapshots for the new selector shape
  • New unit test that bounds selector size with 12 stacked variants
  • New browser tests in packages/tailwindcss/tests/ui.spec.ts, run in Chromium, Firefox, and WebKit. They cover group-*/peer-* focus and blur, @apply inside a complex selector, specificity, stacked groups in either order, and compound group-peer-*/peer-group-*. The pre-PR selectors pass all of them too, so behavior is unchanged
  • pnpm run test and pnpm run test:ui pass

🤖 Generated with Claude Code

@RobinMalfait
RobinMalfait requested a review from a team as a code owner September 25, 2026 12:50
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Walkthrough

The group-* and peer-* variants now place the target selector inside :is(...). Tests update expected selectors across built-in, custom, plugin-defined, prefixed, and IntelliSense variants. A regression test checks output length for stacked variants. Browser tests cover focus behavior, specificity, named groups, and compound variants. The changelog adds an entry about style invalidation performance.

Priority: ➖ Normal

Merge Risk: 🔵 Low · up to e50bb

Group and peer utilities can unexpectedly style SVG elements when a default HTML namespace is declared. This bounded compatibility issue should be fixed or explicitly accepted before merging.

Architecture Summary

Architecture risk: 🔵 Low · up to e50bb

The change affects 2 systems.

Changed systems: packages/tailwindcss, CHANGELOG.md

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — packages/tailwindcss (library) was modified; 7 changed files map to changed impact.
  • observed — CHANGELOG.md (service) was modified; 1 changed file maps to changed impact.

Before / after behavior

  • observed — Modified behavior in CHANGELOG.md: Added an Unreleased changelog entry for improved style invalidation performance of group-* and peer-* variants, linked to PR #20513.
  • observed — Modified behavior in packages/tailwindcss/src/compat/plugin-api.test.ts: The group-hocus snapshot now places the utility selector inside :is() with the combined hover/focus group selector, replacing the previous utility-selector wrapper.
  • observed — Modified behavior in packages/tailwindcss/src/compat/plugin-api.test.ts: The selector-array snapshot places each group-hocus utility selector inside its corresponding :is() group selector instead of scoping the utility selector around *.
  • observed — Modified behavior in packages/tailwindcss/src/compat/plugin-api.test.ts: The object-syntax @slot snapshot uses the same updated :is() form for its hover and focus group selectors.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: improving style invalidation performance for group-* and peer-* variants.
Description check ✅ Passed The description directly explains the selector changes, performance goal, behavior considerations, benchmarks, edge case, and test plan.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[Medium risk] Changes how group and peer variant selectors are generated.

The PR appears safe to merge under its stated namespace trade-off.

Reviews (2) · Last reviewed commit: "update changelog"

@RobinMalfait
RobinMalfait force-pushed the fix/improve-group-and-peer-performance branch from ff1fe10 to 15941da Compare September 25, 2026 13:44
RobinMalfait and others added 3 commits September 25, 2026 15:47
Generate `:is(<group> &)` instead of `&:is(<group> *)` (and the `~`
equivalent for `peer-*`). The target now sits in the subject position, so
browsers can limit style invalidation to the elements that carry the utility
instead of every descendant or following sibling of the group/peer.

Matching behavior and specificity are unchanged. The outer `:is(…)` keeps
compound variants such as `has-group-*` behaving the same.

The one accepted difference: with a default `@namespace`, compound variants
such as `group-group-*` no longer restrict the target to that namespace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Verify matching behavior, specificity, and stacked and compound variants in
Chromium, Firefox, and WebKit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@RobinMalfait
RobinMalfait force-pushed the fix/improve-group-and-peer-performance branch from 15941da to e50bb1c Compare September 25, 2026 13:47

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 78ab2eb5-e764-4b39-82c1-3ea64de33979

📥 Commits

Reviewing files that changed from the base of the PR and between ff1fe10 and e50bb1c.

📒 Files selected for processing (7)
  • packages/tailwindcss/src/compat/plugin-api.test.ts
  • packages/tailwindcss/src/index.test.ts
  • packages/tailwindcss/src/intellisense.test.ts
  • packages/tailwindcss/src/prefix.test.ts
  • packages/tailwindcss/src/variants.test.ts
  • packages/tailwindcss/src/variants.ts
  • packages/tailwindcss/tests/ui.spec.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.

//
// The `:is` wrapper also keeps `has-group-*` from anchoring the group
// itself to the element carrying the utility.
node.selector = `:is(${selector} &)`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '575,685p' packages/tailwindcss/src/variants.ts
rg -n '`@namespace`|namespace' packages/tailwindcss/tests/ui.spec.ts packages/tailwindcss/src/variants.test.ts

Repository: tailwindlabs/tailwindcss

Length of output: 4381


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- diff ---'
git diff --no-ext-diff --unified=30 9798a8ab5b48abf483c93265f96d499ee3755d4d e50bb1c5def9e870cfc7d612c086674619b65609 -- packages/tailwindcss/src/variants.ts packages/tailwindcss/src/variants.test.ts packages/tailwindcss/tests/ui.spec.ts
printf '%s\n' '--- related test references ---'
rg -n -C 5 'group-|peer-|namespace|SVG|svg|has-group|has-peer' packages/tailwindcss/src/variants.test.ts packages/tailwindcss/tests/ui.spec.ts

Repository: tailwindlabs/tailwindcss

Length of output: 41802


🌐 Web query:

Selectors Level 4 default namespace universal selector :is specificity CSS Namespaces

💡 Result:

<source_evidence>
<source>
<title>Selectors Level 4</title>
<location>https://www.w3.org/TR/selectors-4/</location>
<excerpt>| Pattern | Represents | Section | Level | | --- | --- | --- | --- | | `*` | any element | § 5.2 Universal selector | 2 | | `E` | an element of type E | § 5.1 Type (tag name) selector | 1 | ... | `E:is(s1, s2, …)` | an E element that matches compound selector s1 and/or compound selector s2 | § 4.2 The Matches-Any Pseudo-class: :is() | 4 | ... If a selector would otherwise match a featureless element, except for the existence of the default namespace [CSS-NAMESPACES-3] (because featureless elements do not have a namespace unless otherwise defined), the default namespace does not prevent the match. For example, the shadow host in a shadow tree is featureless, and can’t be matched by any pseudo-class except for :host and :host-context() (or combinations including those, such as :is(:host, :root)). Logical combinations like :not(.foo:host) will never match the host element (even if it doesn’t have a &quot;foo&quot; class), because not all of the simple selectors in .foo:host are allowed to match the shadow host. ... the shadow host, even tho ... In general, you can’ ... element without explicitly using one of the ... selectors it’s allowed ... match, to avoid accidentally selecting one of these elements (which are otherwise intentionally easy to not think about). For example, * will ... match a featureless element. ... Certain selectors support namespace prefixes. The mechanism by which namespace prefixes are declared should be specified by the language that uses Selectors. If the language does not specify a namespace prefix declaration mechanism, then no prefixes are declared. In CSS, namespace prefixes are declared with the `@namespace` rule. [CSS3NAMESPACE] ... .2. The Matches-Any Pseudo-class: :is() ... The matches-any pseudo ... , :is(), is a functional ... sole argument. ... Note: The specificity of the :is() pseudo-class is replaced by the specificity of its most specific argument. Thus, a selector written with :is() does not necessarily have equivalent specificity to the equivalent selector written without :is() For example, if we have :is(ul, ol, .list) &gt; [hidden] and ul &gt; [hidden], ol &gt; [hidden], .list &gt; [hidden] a [hidden] child of an `ol` matches the first selector with a specificity of (0,2,0) whereas it matches the second selector with a specificity of (0,1,1). See § 15 Calculating a selector’s specificity. ... Default namespace declarations do not affect the compound selector representing the subject of any selector within a :is() pseudo-class, unless that compound selector contains an explicit universal selector or type selector. For example, the following selector matches any element that is being hovered or focused, regardless of its namespace. In particular, it is not limited to only matching elements in the default namespace that are being hovered or focused. ``` *|*:is(:hover, :focus) ``` ... The following selector, however, represents only hovered or focused elements that are in the default namespace, because it uses an explicit universal selector within the :is() notation: ``` *|*:is(*:hover, *:focus) ```</excerpt>
</source>
<source>
<title>Universal selectors - CSS | MDN</title>
<location>https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Universal_selectors</location>
<excerpt>Universal selectors - CSS | MDN # Universal selectors Baseline Widely available This feature is well established and works across many devices and browser versions. It’s been available across browsers since July 2015. - Learn more - See full compatibility The CSS universal selector (`*`) matches elements of any type. css ``` /* Selects all elements */ * { color: green; } ``` The universal selector is a special type selector and can therefore be namespaced when using `@namespace`. This is useful when dealing with documents containing multiple namespaces such as HTML with inline SVG or MathML, or XML that mixes multiple vocabularies. - `ns|*` - matches all elements in namespace ns - `*|*` - matches all elements - `|*` - matches all elements without any declared namespace Note: The universal selector (`*`) matches elements only. It does not directly match pseudo-elements by itself. To match all `::before` pseudo-elements on a page, for example, you would have to use a selector like `*::before`. This works because the `*` matches all elements, and the `::before` pseudo-element is available on all elements. ## Syntax ``` * { style properties } ``` The asterisk is optional with simple selectors. For instance, `*.warning` and `.warning` are equivalent. ### CSS ``` * [lang^=&quot;en&quot;] { color: green; } *.warning { color: red; } *`#maincontent` { border: 1px solid blue; } .floating { float: left; } /* automatically clear the next sibling after a floating element */ .floating + * { clear: left; } ``` ### HTML ``` &lt;p class=&quot;warning&quot;&gt; &lt;span lang=&quot;en-us&quot;&gt;A green span&lt;/span&gt; in a red paragraph. &lt;/p&gt; &lt;p id=&quot;maincontent&quot; lang=&quot;en-gb&quot;&gt; &lt;span class=&quot;warning&quot;&gt;A red span&lt;/span&gt; in a green paragraph. &lt;/p&gt; ``` ### Namespaces In this example the selector will only match elements in the example namespace. ``` `@namespace` example url(&quot;http://www.example.com/&quot;); example|* { color: blue; } ``` ## Specifications | Specification | | --- | | Selectors Level 4 # the-universal-selector |</excerpt>
</source>
<source>
<title>CSS Namespaces Module Level 3</title>
<location>https://drafts.csswg.org/css-namespaces/</location>
<excerpt>This CSS Namespaces module defines the syntax for using namespaces in CSS. It defines the `@namespace` rule for declaring the default namespace and binding namespaces to namespace prefixes, and it also defines a syntax that other specifications can adopt for using those prefixes in namespace-qualified names. ... This CSS Namespaces module defines syntax for using namespaces in CSS. It defines the `@namespace` rule for declaring a default namespace and for binding namespaces to namespace prefixes. It also defines a syntax for using those prefixes to represent namespace-qualified names. It does not define where such names are valid or what they mean: that depends on their context and is defined by a host language, such as Selectors ([SELECT]), that references the syntax defined in the CSS Namespaces module. ... The `@namespace` at-rule declares a namespace prefix and associates it with a given namespace name (a string). This namespace prefix can then be used in namespace-qualified names such as the CSS qualified names defined below. ... The first rule declares a default namespace `http://www.w3.org/1999/xhtml` to be applied to names that have no explicit namespace component. ... If in the namespace declaration the namespace prefix is omitted, then the namespace so declared is the default namespace. The default namespace may apply to names that have no explicit namespace prefix: modules that employ namespace prefixes must define in which contexts the default namespace applies. For example, following [XML-NAMES], in Selectors [SELECT] the default namespace applies to type selectors—​but it does not apply to attribute selectors. There is no default value for the default namespace: modules that assign unqualified names to the default namespace must define how those unqualified names are to be interpreted when no default namespace is declared. ... Note: Note that using default namespaces in conjunction with type selectors can cause UAs that support default namespaces and UAs that don’t support default namespaces to interpret selectors differently. ... CSS qualified names can be used in (for example) selectors and property values as described in other modules. Those modules must define handling of namespace prefixes that have not been properly declared. Such handling should treat undeclared namespace prefixes as a parsing error that will cause the selector or declaration (etc.) to be considered invalid and, in CSS, ignored. For example, the Selectors module [SELECT] defines a type selector with an undeclared namespace prefix to be an invalid selector, and CSS [CSS21] requires style rules with an invalid selector to be completely ignored. ... CSS-SYNTAX-3] defines the following terms: - at-rule ... CSS-VALUES-4 ... the following terms ... - ... - [SELECTORS-4] defines the following terms: - type selector ... [SELECT] : Tantek Çelik; et al. ... ors Level 3. URL: https://drafts.csswg.org/selectors-3/ ... [SELECTORS-4] : Elika Etemad; Tab Atkins Jr.. Selectors Level 4. URL: https://drafts.csswg.org/selectors/</excerpt>
</source>
<source>
<title>Selectors Level 4</title>
<location>https://drafts.csswg.org/selectors-4/</location>
<excerpt>| Pattern | Represents | Section | Level | | --- | --- | --- | --- | | `*` | any element | § 5.2 Universal selector | 2 | | `E` | an element of type E | § 5.1 Type (tag name) selector | 1 | ... | `E:is(s1, s2, …)` | an E element that matches compound selector s1 and/or compound selector s2 | § 4.2 The Matches-Any Pseudo-class: :is() | 4 | ... If a selector would otherwise match a featureless element, except for the existence of the default namespace [CSS-NAMESPACES-3] (because featureless elements do not have a namespace unless otherwise defined), the default namespace does not prevent the match. ... CSS3NAMESPACE] ... . The Matches ... Pseudo-class: :is() ... Note: The specificity of the :is() pseudo-class is replaced by the specificity of its most specific argument. Thus, a selector written with :is() does not necessarily have equivalent specificity to the equivalent selector written without :is() For example, if we have :is(ul, ol, .list) &gt; [hidden] and ul &gt; [hidden], ol &gt; [hidden], .list &gt; [hidden] a [hidden] child of an ol matches the first selector with a specificity of (0,2,0) whereas it matches the second selector with a specificity of (0,1,1). See § 15 Calculating a selector’s specificity. ... Default namespace declarations do not affect the compound selector representing the subject of any selector within a :is() pseudo-class, unless that compound selector contains an explicit universal selector or type selector. ... , the following selector matches ... is being hovered ... focused, regardless of ... namespace. In particular, it ... matching elements in ... default namespace that are being hovered or focused ... The following selector, however, represents only hovered or focused elements that are in the default namespace, because it uses an explicit universal selector within the :is() notation: ... ``` *|*:is(*:hover, *:focus) ``` ... The universal selector is a special type selector, that represents an element of any element type. ... It is written as a CSS qualified name with an asterisk (`*` U+002A) as the local name. Like a type selector, the universal selector can be qualified by a namespace, restricting it to only elements belonging to that namespace, and is affected by a default namespace as defined in § 5.3 Namespaces in Elemental Selectors. ... Unless an element is featureless, the presence of a universal selector has no effect on whether the element matches the selector. (Featureless elements do not match any selector, including the universal selector.) ... The universal selector follows the same syntax rules as other type selectors: only one can appear per compound selector, and it must be the first simple selector in the compound selector. ... ### 5.3. Namespaces in Elemental Selectors ... Type selectors and universal selectors allow an optional namespace component: a namespace prefix that has been previously declared may be prepended to the element name separated by the namespace separator “vertical bar” (`|` U+007C). (See, e.g., [XML-NAMES] for the use of namespaces in XML.) It has the following meaning in each form: ... `ns|E` elements with name E in namespace ns`*|E` elements with name E in any namespace, including those without a namespace`|E` elements with name E without a namespace`E` if no default namespace has been declared for selectors, this is equivalent to *|E. Otherwise it is equivalent to ns|E where ns is the default namespace. ... If a default namespace is declared, compound selectors without type selectors in them still only match elements in that default namespace. ... matches elements in the &quot;http://example.com/foo ... type name ( ... paired with the namespace in the DOM) appeared ... A type selector or universal selector containing a namespace prefix that has not been previously declared is an invalid selector. ... declared may be prep ... separator &quot;vertical bar ... attr). An asterisk ... namespace prefix indicating that the ... is to match all attribute names</excerpt>
</source>
<source>
<title>:is() CSS pseudo-class - CSS | MDN</title>
<location>https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:is</location>
<excerpt>:is() CSS pseudo-class - CSS | MDN # `:is()` CSS pseudo-class Baseline Widely available This feature is well established and works across many devices and browser versions. It’s been available across browsers since January 2021. - See full compatibility - Learn more The `:is()` CSS pseudo-class function takes a selector list as its argument, and selects any element that can be selected by one of the selectors in that list. This is useful for writing large selectors in a more compact form. Note: Originally named `:matches()` (and `:any()`), this selector was renamed to `:is()` in CSSWG issue `#3258`. ## Try it ``` ol { list-style-type: upper-alpha; color: darkblue; } :is(ol, ul, menu:unsupported) :is(ol, ul) { color: green; } :is(ol, ul) :is(ol, ul) ol { list-style-type: lower-greek; color: chocolate; } ``` ``` &lt;ol&gt; &lt;li&gt;Saturn&lt;/li&gt; &lt;li&gt; &lt;ul&gt; &lt;li&gt;Mimas&lt;/li&gt; &lt;li&gt;Enceladus&lt;/li&gt; &lt;li&gt; &lt;ol&gt; &lt;li&gt;Voyager&lt;/li&gt; &lt;li&gt;Cassini&lt;/li&gt; &lt;/ol&gt; &lt;/li&gt; &lt;li&gt;Tethys&lt;/li&gt; &lt;/ul&gt; &lt;/li&gt; &lt;li&gt;Uranus&lt;/li&gt; &lt;li&gt; &lt;ol&gt; &lt;li&gt;Titania&lt;/li&gt; &lt;li&gt;Oberon&lt;/li&gt; &lt;/ol&gt; &lt;/li&gt; &lt;/ol&gt; ``` ``` :is(&lt;forgiving-selector-list&gt;) { /* ... */ } ``` ### Parameters The `:is()` pseudo-class requires a selector list, a comma-separated list of one or more selectors as its argument. The list must not contain a pseudo-element, but any other simple, compound, and complex selectors are allowed. ### Difference between :is() and :where() The difference between the two is that `:is()` counts towards the specificity of the overall selector (it takes the specificity of its most specific argument), whereas `:where()` has a specificity value of 0. This is demonstrated by the example on the `:where()` reference page. ### Forgiving Selector Parsing The specification defines `:is()` and `:where()` as accepting a forgiving selector list. In CSS when using a selector list, if any of the selectors are invalid then the whole list is deemed invalid. When using `:is()` or `:where()` instead of the whole list of selectors being deemed invalid if one fails to parse, the incorrect or unsupported selector will be ignored and the others used. ``` :is(:valid, :unsupported) { /* … */ } ``` Will still parse correctly and match `:valid` even in browsers which don&`#39`;t support `:unsupported`, whereas: ``` :valid, :unsupported { /* … */ } ``` Will be ignored in browsers which don&`#39`;t support `:unsupported` even if they support `:valid`. ### Simplifying list selectors The `:is()` pseudo-class can greatly simplify your CSS selectors. For example, take the following CSS: ``` /* 3-deep (or more) unordered lists use a square */ ol ol ul, ol ul ul, ol menu ul, ol ol menu, ol ul menu, ol menu menu, ul ol ul, ul ul ul, ul menu ul, ul ol menu, ul ul menu, ul menu menu, menu ol ul, menu ul ul, menu menu ul, menu ol menu, menu ul menu, menu menu menu { list-style-type: square; } ``` You can replace it with: ``` /* 3-deep (or more) unordered lists use a square */ :is(ol, ul, menu) :is(ol, ul, menu) :is(ul, menu) { list-style-type: square; } ``` ### Simplifying section selectors The `:is()` pseudo-class is particularly useful when dealing with HTML sections and headings. Since ` `, ` `, ` `, and ` ` are commonly nested together, without `:is()`, styling them to match one another can be tricky. For example, without `:is()`, styling all the h1 elements at different depths could be very complicated: ``` /* Level 0 */ h1 { font-size: 30px; } /* Level 1 */ section h1, article h1, aside h1, nav h1 { font-size: 25px; } /* Level 2 */ section section h1, section article h1, section aside h1, section nav h1, article section h1, article article h1, article aside h1, article nav h1, aside section h1, aside article h1, aside aside h1, aside nav h1, nav section h1, nav article h1, nav aside h1, nav nav h1 { font-size: 20px; } /* Level 3 */ /*…[truncated]</excerpt>
</source>
</source_evidence>

Citations:

- 1: https://www.w3.org/TR/selectors-4/
- 2: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/Universal_selectors
- 3: https://drafts.csswg.org/css-namespaces/
- 4: https://drafts.csswg.org/selectors-4/
- 5: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:is
- 6: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@namespace

Preserve the default HTML namespace restriction.

With a default HTML namespace, the old ordinary group and peer selectors restrict the utility target to HTML elements. Moving the target compound into :is() removes that restriction, so an SVG element with the utility class can match.

Keep the target inside :is() for style invalidation, but add an explicit zero-specificity universal selector. Add a browser test with HTML and inline SVG targets.

Suggested fix
-      node.selector = `:is(${selector} &)`
+      node.selector = `:is(${selector} &:is(*))`
...
-      node.selector = `:is(${selector} ~ &)`
+      node.selector = `:is(${selector} ~ &:is(*))`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
node.selector = `:is(${selector} &)`
node.selector = `:is(${selector} &:is(*))`

@RobinMalfait
RobinMalfait merged commit fa81d69 into main Sep 25, 2026
10 checks passed
@RobinMalfait
RobinMalfait deleted the fix/improve-group-and-peer-performance branch September 25, 2026 19:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants