Skip to content

Improve handling of @scope related at-rules - #20369

Open
RobinMalfait wants to merge 8 commits into
mainfrom
fix/issue-18961
Open

Improve handling of @scope related at-rules#20369
RobinMalfait wants to merge 8 commits into
mainfrom
fix/issue-18961

Conversation

@RobinMalfait

Copy link
Copy Markdown
Member

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:

@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:

@custom-variant blue {
  @scope ([data-theme='blue']) to ([data-theme]) {
    @slot;
  }
}

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

.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 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:

@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:

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

This says that you need a structure roughly like this:

<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:

@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:

<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:

@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:

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

gets turned into:

@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):

  • 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:

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

compiles to:

@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:

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

compiles to:

@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:

/* 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:

@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:

@scope (.from) to (.to) {
  color: red;
}

Then Lightning CSS crashes with an unexpected input. The spec says 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.

@scope (.foo) {
  border: 1px solid black;
}

is equivalent to:

scope (.foo) {
  :where(:scope) {
    border: 1px solid black;
  }
}

So this is what we will do as well:

@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
  2. 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
  3. Added tests for the hoisting logic, prelude resolution for & selectors
  4. 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.

UditDewan and others added 7 commits July 28, 2026 14:59
Custom variants defined with an @scope at-rule emitted the @scope block
nested inside the generated utility class instead of wrapping it, so the
resulting CSS had no effect in browsers. Treat @scope like other
conditional at-rules (@media, @supports, @container) when resolving
nesting, so it is hoisted above the generated style rules.

Fixes #18961
We need to work in 2 modes here:

1. For when we use `@custom-variant`. This is because we built up the
   rule with nesting, e.g.:
   ```css
   @custom-variant scoped {
     @scope (.from) to (.to) {
       @slot;
     }
   }
   ```

   If you use it, you can use `scoped:flex`, then we essentially build:
   ```css
   .scoped\:flex {
     @scope (.from) to (.to) {
       display: flex;
     }
   }
   ```

   The problem with this is that the `.scoped\:flex` is not inside the
   `@scope`, and therefore the `.from` → `.to` sandwich doesn't work.

   For this reason, we will mark all CSS defined by the
   `@custom-variant` with a special `source: 'variant'` flag. And all
   the other CSS is defined by `source: 'user'`.

   The shorthand `@custom-variant` without `@slot`, the arbitrary
   variants `[@scope…]:flex`, and variants defined by the `addVariant`
   and `matchVariant` APIs will all have this additional treatment.

2. When using `@scope` in user-defined CSS (aka, not via a Tailwind
   variant), then we don't want to swap this `@scope` at-rule, and the
   rule with the class selector because it will change its meaning.

   We will still handle the `@scope` nesting because Lightning CSS will
   otherwise generate invalid CSS.

   Input:
   ```css
   .parent {
     @scope (.from) to (.to) {
       .sandwhich {
         color: red;
       }
     }
   }
   ```
   Lightning CSS will turn this into:
   ```css
   @scope (.from) to (.to) {
     :scope .sandwhich {
       color: red;
     }
   }
   ```
   Losing the concept of the `.parent` class. Instead we will apply our
   flattening rules and hoist the `@scope` out. This also requires us to
   update the `<scope-start>` with the parent information:
   ```css
   @scope (.parent .from) to (.to) {
     .sandwhich {
       color: red;
     }
   }
   ```

   Another fix for Lightning CSS is to ensure that we introduce
   `:where(:scope)` rules for when there are bare declarations in the
   `@scope` at-rule. Otherwise Lightning CSS would crash.
   ```css
   .parent {
     @scope (.from) to (.to) {
       color: red;
     }
   }
   ```

   This would crash, so we make sure that this turns into:
   ```css
   @scope (.parent .from) to (.to) {
     :where(:scope) {
       color: red;
     }
   }
   ```
@RobinMalfait
RobinMalfait requested a review from a team as a code owner July 30, 2026 23:11
@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 5/5

The PR appears safe to merge with no concrete changed-code defects identified.

The source-context markers are preserved through optimization, scope preludes distinguish start and end selectors correctly for valid parsed CSS, and the changed composition paths are covered across AST, compiler, plugin, and browser-level tests.

Reviews (1): Last reviewed commit: "update changelog references" | Re-trigger Greptile

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

An error occurred during the review process. Please try again later.

Walkthrough

Added full @scope support across AST optimization, nesting transformation, and variant generation. Scoped rules now preserve source context, resolve selectors and scope boundaries, hoist correctly, and wrap standalone declarations with :where(:scope). Custom, arbitrary, addVariant, and matchVariant variants support scoped output. Unit, integration, and browser tests cover authored scopes, nested scopes, selector composition, and browser support.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the PR's primary change: improving handling of @scope at-rules.
Description check ✅ Passed The description is detailed and directly explains the @scope fixes, implementation approach, and test coverage.
Linked Issues check ✅ Passed The changes address both linked issues by fixing custom @scope variants and correctly hoisting and transforming scoped rules.
Out of Scope Changes check ✅ Passed The implementation, changelog, and extensive tests are all directly related to the linked @scope issues and stated objectives.

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.

@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.

🧹 Nitpick comments (3)
packages/tailwindcss/src/index.test.ts (1)

4898-4924: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a unit snapshot for & in the <scope-end> limit.

The PR resolves & differently in scope-start vs scope-end, and ui.spec.ts relies on build-time resolution of to (& .limit), but no snapshot here pins that output. A case such as .parent { @scope (& > .a) to (& .b) { … } } would lock the distinct resolution rules in a fast unit test rather than only in the browser suite.

packages/tailwindcss/tests/ui.spec.ts (1)

2320-2357: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The inner to (.…-inner-limit) limit is never exercised.

No element with class ${prefix}-inner-limit exists in structure(), so the inner scope's limit is inert in this test. Adding a probe nested inside an *-inner-limit element would actually validate that the inner limit survives nesting/hoisting.

🧪 Suggested fixture addition
         <!-- Middle is beyond the outer limit -->
         <div class="${prefix}-outer">
           <div class="${prefix}-outer-limit">
             <div class="${prefix}-middle">
               <div class="${prefix}-inner" id="${prefix}-beyond">beyond</div>
             </div>
           </div>
         </div>
+
+        <!-- Probe beyond the inner limit -->
+        <div class="${prefix}-outer">
+          <div class="${prefix}-middle">
+            <div class="${prefix}-inner">
+              <div class="${prefix}-inner-limit">
+                <div class="${prefix}-inner" id="${prefix}-inner-beyond">inner beyond</div>
+              </div>
+            </div>
+          </div>
+        </div>
packages/tailwindcss/src/compat/plugin-api.ts (1)

611-623: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive usesAtScope from the parsed AST instead of a substring test.

r.includes('@scope') also matches @scope inside a string/attribute value or comment, which then restructures the output with inert context wrappers. Since r is parsed on the next line anyway, detect it structurally — the same way Variants.fromAst does in packages/tailwindcss/src/variants.ts (lines 93-103).

♻️ Proposed refactor
     if (r.trim().endsWith('}')) {
-      let usesAtScope = r.includes('`@scope`')
       let updatedCSS = r.replace('}', '{`@slot`}}')
       let ast = CSS.parse(updatedCSS)
+
+      let usesAtScope = false
+      walk(ast, (node) => {
+        if (node.kind === 'at-rule' && node.name === '`@scope`') {
+          usesAtScope = true
+          return WalkAction.Stop
+        }
+      })
 
       if (usesAtScope) {

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6a5f16dc-b903-4cff-98b0-856f63db807e

📥 Commits

Reviewing files that changed from the base of the PR and between 6de87c6 and f184f4f.

📒 Files selected for processing (10)
  • CHANGELOG.md
  • packages/tailwindcss/src/ast.test.ts
  • packages/tailwindcss/src/ast.ts
  • packages/tailwindcss/src/compat/plugin-api.test.ts
  • packages/tailwindcss/src/compat/plugin-api.ts
  • packages/tailwindcss/src/compile.ts
  • packages/tailwindcss/src/index.test.ts
  • packages/tailwindcss/src/index.ts
  • packages/tailwindcss/src/variants.ts
  • packages/tailwindcss/tests/ui.spec.ts

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.

Custom variants don't work with @scope

2 participants