No description
# 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>
|
||
|---|---|---|
| .github | ||
| crates | ||
| integrations | ||
| packages | ||
| patches | ||
| playgrounds | ||
| scripts | ||
| .gitignore | ||
| .prettierignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CHANGELOG.md | ||
| LICENSE | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| rust-toolchain.toml | ||
| turbo.json | ||
| vitest.config.mts | ||
A utility-first CSS framework for rapidly building custom user interfaces.
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.