No description
Find a file
Robin Malfait fa81d697fe
Improve style invalidation performance of group-* and peer-* variants (#20513)
# 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:
```css
.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:
```css
: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).

```css
/* 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

- [x] Updated unit test snapshots for the new selector shape
- [x] New unit test that bounds selector size with 12 stacked variants
- [x] 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
- [x] `pnpm run test` and `pnpm run test:ui` pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 21:32:41 +02:00
.github Bump dependencies (#20381) 2026-08-04 13:55:03 +02:00
crates Move debug logs in .tailwindcss folder (#20416) 2026-08-13 23:16:30 +02:00
integrations Do not force full page reloads when using @tailwindcss/vite (#20414) 2026-08-13 17:00:08 +02:00
packages Improve style invalidation performance of group-* and peer-* variants (#20513) 2026-09-25 21:32:41 +02:00
patches Use wasm as a fallback for @tailwindcss/oxide (#20383) 2026-08-04 18:41:43 +02:00
playgrounds migrate to pnpm v11 (#20273) 2026-06-25 19:14:10 +02:00
scripts migrate to pnpm v11 (#20273) 2026-06-25 19:14:10 +02:00
.gitignore Fix slow unit test (#17465) 2025-03-31 15:26:01 +02:00
.prettierignore Bump dependencies (#19608) 2026-02-04 12:38:50 +01:00
Cargo.lock Visualize scanner related tests (#20412) 2026-08-13 12:45:40 +02:00
Cargo.toml Hoist oxide/crates to just crates (#13333) 2024-03-23 09:00:48 -04:00
CHANGELOG.md Improve style invalidation performance of group-* and peer-* variants (#20513) 2026-09-25 21:32:41 +02:00
LICENSE Add README, LICENSE, and CONTRIBUTING (#13088) 2024-03-05 14:45:39 -05:00
package.json Bump dependencies (#20381) 2026-08-04 13:55:03 +02:00
pnpm-lock.yaml Use wasm as a fallback for @tailwindcss/oxide (#20383) 2026-08-04 18:41:43 +02:00
pnpm-workspace.yaml Use wasm as a fallback for @tailwindcss/oxide (#20383) 2026-08-04 18:41:43 +02:00
README.md docs: fix GitHub links to tailwindlabs org (#19686) 2026-02-17 13:06:49 +01:00
rust-toolchain.toml Bump NAPI related dependencies (#19982) 2026-04-26 17:49:06 +02:00
turbo.json Bump dependencies (#20381) 2026-08-04 13:55:03 +02:00
vitest.config.mts Bump dependencies (#20381) 2026-08-04 13:55:03 +02:00

Tailwind CSS

A utility-first CSS framework for rapidly building custom user interfaces.

Build Status Total Downloads Latest Release License


Documentation

For full documentation, visit tailwindcss.com.

Community

For help, discussion about best practices, or feature ideas:

Discuss Tailwind CSS on GitHub

Contributing

If you're interested in contributing to Tailwind CSS, please read our contributing docs before submitting a pull request.