tailwindcss/packages
Robin Malfait 423aee5309
Improve handling of @scope related at-rules (#20369)
Continuation of #20344, original commits of that PR are present in this
PR as well.


Before we talk about the issues, there is some jargon I'll be using that
might be unfamiliar:
```css
@scope (.from) to (.to) {
  /*   ^^^^^^^^^^^^^^^^    This is the prelude */
  /*   ^^^^^^^             This is the `scope-start` */
  /*              ^^^^^    This is the `scope-end` */
}

/* The `scope-start` is optional, if you only want `scope-end`: */
@scope to (.to) {
}

/* The `scope-end` is optional, if you only want `scope-start`: */
@scope (.from) {
}
```

This PR contains multiple fixes, but they are all related to the
`@scope` at-rule. The `@scope` at-rule is a beast, it's an at-rule where
we do have multiple selectors in the prelude, which in turn can contain
`&` rules. If you use `&` inside of an `@scope` rule, its meaning
changes compared to what `&` typically means and it is even different
whether it exists in `scope-start` or `scope-end`.

Let's start with the original issue, if you define a custom variant
like:
```css
@custom-variant blue {
  @scope ([data-theme='blue']) to ([data-theme]) {
    @slot;
  }
}
```

Then using `blue:bg-blue-500` used to generate:

```css
.blue\:bg-blue-500 {
  @scope ([data-theme='blue']) to ([data-theme]) {
    background-color: var(--color-blue-500);
  }
}
```

This is because internally we start with the AST node representing
`bg-blue-500`, then we wrap all the `variant` related nodes around it
(`blue`), and then wrap everything in the final rule with a selector
representing the class (`.blue\:bg-blue-500`).

This is incorrect because you want that sandwich effect where any
`.blue\:bg-blue-500` class between an element with the
`[data-theme="blue"]` attribute and another nested `[data-theme]`
attribute.

The other [PR](#20344) solved this by simply making sure that the
`@scope` rule gets hoisted to the top. For this particular example that
was enough.

However, that would result in a ton more issues. This hoisting or
swapping with the rule is only "safe" in a `custom-variant`.

So similar to the other PR, this PR wraps this properly:

```css
@scope ([data-theme='blue']) to ([data-theme]) {
  .blue\:bg-blue-500 {
    background-color: var(--color-blue-500);
  }
}
```

The tricky part is that the nesting pass runs on _all_ CSS, custom
variants _and_ your own CSS. Hoisting an `@scope` rule out of the rule
it is nested in changes its meaning.

Take a look at this example:

```css
.parent {
  @scope (.from) to (.to) {
    * {
      border: 1px solid black;
    }
  }
}
```

This says that you need a structure roughly like this:
```html
<div class="parent">
  <div class="from">
    <div>This element will get a border</div>

    <div class="to">
      <div>This element won't get a border, it's beyond the scope</div>
    </div>
  </div>
</div>
```

If we had switched it:
```css
@scope (.from) to (.to) {
  .parent {
    * {
      border: 1px solid black;
    }
  }
}
```

Then this requires that an element with a class of `.parent` lives
between the
`.from` scope-start and `.to` scope-end:
```html
<div class="from">
  <div class="parent">
    <div>This element will get a border</div>

    <div class="to">
      <div>This element won't get a border, it's beyond the scope</div>
    </div>
  </div>
</div>
```

Subtle, but it's definitely wrong.

Instead, we will apply our flattening rules, such that the `@scope` gets
hoisted, but we have to bring that `.parent` selector requirement inside
of the `@scope` prelude, more specifically inside the `scope-start`:

```css
@scope (.parent .from) to (.to) {
  * {
    border: 1px solid black;
  }
}
```

We _could_ have left this as-is, but there is a Lightning CSS bug where
the original CSS:
```css
.parent {
  @scope (.from) to (.to) {
    * {
      border: 1px solid black;
    }
  }
}
```

gets turned into:
```css
@scope (.from) to (.to) {
  :scope * {
    border: 1px solid #000;
  }
}
```

Notice how the `.parent` class is not found at all? (I will open some
issues on the Lightning CSS repo (and PRs to fix this). But it's a good
fix to have in Tailwind CSS as well, just because a lot of tooling is
already using Lightning CSS on top of Tailwind CSS, so we need an entire
chain to be updated for this to work.)

Side note: the version we generate is untouched by Lightning CSS.

## Resolving `&` in the prelude

`&` resolves differently depending on which scope selector you're
looking at
([spec](https://drafts.csswg.org/css-nesting-1/#nesting-at-scope)):

- In the `<scope-start>` selector, `&` refers to the elements matched by
the nearest ancestor style rule (the utility, for variants).
- In the `<scope-end>` selector, `&` refers to the scoping root and
behaves like `:where(:scope)`. `:scope` might work, but that has a
specificity of `0,1,0` instead of `0,0,0`.

So this input:

```css
.parent {
  @scope (& > .scope) to (& .limit) {
    .content {
      color: red;
    }
  }
}
```

compiles to:

```css
@scope (.parent > .scope) to (:where(:scope) .limit) {
  .content {
    color: red;
  }
}
```

The substitution uses the exact same logic as `&` in nested style rule
selectors: `:is(…)` semantics by default, dropping the `:is(…)` whenever
the substitution is provably equivalent.

So a complex parent compiles to `@scope (.a .b > .from)` rather than
`@scope (:is(.a .b) > .from)`, while `.card&` inside a `main` rule keeps
it (`@scope (.card:is(main))`) because substituting the type selector
as-is would produce invalid CSS.

For user CSS, every selector in a `<scope-start>` selector list is
relative to the parent rule, whether it uses `&` or not, just like
nested style rule selectors:

```css
.parent {
  @scope (.a, & > .b) {
    .inside {
      color: red;
    }
  }
}
```

compiles to:

```css
@scope (.parent .a, .parent > .b) {
  .inside {
    color: red;
  }
}
```

## Controlling the composition with `&`

Because `&` in the `<scope-start>` selector refers to the utility,
variant authors can control where the utility ends up:

```css
/* Default: the utility applies to elements inside the scope */
@custom-variant in-blue {
  @scope ([data-theme='blue']) to ([data-theme]) {
    @slot;
  }
}

/* Trailing `&`: the utility itself becomes the scoping root */
@custom-variant blue-self {
  @scope ([data-theme='blue'] &) to ([data-theme]) {
    @slot;
  }
}

/* Leading `&`: the scoping roots are found inside the utility */
@custom-variant scoped-panel {
  @scope (& .panel) {
    @slot;
  }
}
```

generates:

```css
@scope ([data-theme='blue']) to ([data-theme]) {
  .in-blue\:flex {
    display: flex;
  }
}

@scope ([data-theme='blue'] .blue-self\:flex) to ([data-theme]) {
  :where(:scope) {
    display: flex;
  }
}

@scope (.scoped-panel\:flex .panel) {
  :where(:scope) {
    display: flex;
  }
}
```

Note that when the utility becomes the scoping root, the `<scope-end>`
limit no longer affects the utility itself (a scoping root is never
beyond its own limits), and the declarations apply to the root with zero
specificity. The default composition is usually what you want to get the
sandwich effect, but now you _can_ get the other behavior if you want.

## Bare declarations

There is another small Lightning CSS bug that we want to fix. If you
have the following CSS:
```css
@scope (.from) to (.to) {
  color: red;
}
```

Then Lightning CSS crashes with an unexpected input. The [spec
says](https://drafts.csswg.org/css-cascade-6/#scoped-declarations) that:

> Declarations may be used directly with the body of a `@scope` rule.
Contiguous runs of declarations are wrapped in nested declarations
rules, which match the scoping root with zero specificity.
>
> ```css
> @scope (.foo) {
>   border: 1px solid black;
> }
> ```
> is equivalent to:
> ```css
> scope (.foo) {
>   :where(:scope) {
>     border: 1px solid black;
>   }
> }
> ```

So this is what we will do as well:
```css
@scope (.from) to (.to) {
  :where(:scope) {
    color: red;
  }
}
```

---

One more small detail about the implementation: we do have to track
which CSS is coming from the custom variant, and what is user defined
CSS. To do this, we insert some internal `context` nodes with a `{
source: 'user' }` or `{ source: 'variant' }` so we know when we can just
swap this `@scope` around, or if we just have to handle the nesting and
`&` substitutions.

Fixes: #18961
Closes: #20344

## Test plan

1. Added loads of new tests related to `@scope` in user-land
1. Added loads of new tests related to `@scope` in custom variants
   - When using the `@custom-variant` shorthand syntax
   - When using the `@custom-variant` syntax with `@slot`
   - When using arbitrary variants like `[@scope_(.from)_to_(.to)]:flex`
   - When using `addVariant()` and `matchVariant()` APIs
1. Added tests for the hoisting logic, prelude resolution for `&`
selectors
1. Added browser based UI tests to make sure that the non-flattened
version and flattened version behave the same.


---

Sorry about all the sandwiches, I'm hungry myself.

---------

Co-authored-by: uditDewan <udit.dewan21@gmail.com>
2026-07-31 12:36:44 +02:00
..
@tailwindcss-browser 4.3.3 (#20334) 2026-07-16 07:21:50 -04:00
@tailwindcss-cli 4.3.3 (#20334) 2026-07-16 07:21:50 -04:00
@tailwindcss-node 4.3.3 (#20334) 2026-07-16 07:21:50 -04:00
@tailwindcss-postcss 4.3.3 (#20334) 2026-07-16 07:21:50 -04:00
@tailwindcss-standalone 4.3.3 (#20334) 2026-07-16 07:21:50 -04:00
@tailwindcss-upgrade 4.3.3 (#20334) 2026-07-16 07:21:50 -04:00
@tailwindcss-vite 4.3.3 (#20334) 2026-07-16 07:21:50 -04:00
@tailwindcss-webpack 4.3.3 (#20334) 2026-07-16 07:21:50 -04:00
internal-example-plugin Prefix internal modules with internal-* (#14074) 2024-07-29 10:58:07 -04:00
tailwindcss Improve handling of @scope related at-rules (#20369) 2026-07-31 12:36:44 +02:00
tsconfig.base.json Improve debug logs (#15303) 2024-12-11 15:27:20 +01:00