Simplify @variant usage, allow compound and stacked variants (#19996)

This PR improves and simplifies the `@variant` usage. 

When we originally added support for `@variant`, we wanted to keep
things simple, where we could only use a single variant at a time. The
original PR did have a more complex system with all these features
enabled, but we wanted to make sure that we only introduced the
additional complexity when the community felt like it was needed.

But of course we still wanted to make sure that you could do compound
and stacked variants, it just required some additional code.

For compound variants, where you want to use variant `a` and variant
`b`, you could duplicate the rules as siblings:
```css
.foo {
  @variant a {
    display: flex;
  }

  @variant b {
    display: flex;
  }
}
```

But with this PR, you can comma separate each variant to get the same
effect:
```css
.foo {
  @variant a, b {
    display: flex;
  }
}
```
You can think of this as-if we are expanding this syntax into the
aforementioned syntax. In other words, we would do the duplication for
you.

Additionally, you also want to be able to stack variants. For that you
had to nest your `@variant` rules:
```css
.foo {
  @variant a {
    @variant b {
      display: flex;
    }
  }
}
```

Not the end of the world, but it can get pretty nested if you want to
use multiple variants. Luckily we already have a syntax for this in
normal Tailwind CSS classes: `a🅱️flex`. Which is exactly what we can
use here as well:
```css
.foo {
  @variant a:b {
    display: flex;
  }
}
```
Again, conceptually you can think of this syntax being expanded into the
syntax from above.

Last but not least, we can also combine these:
```css
.foo {
  background: black;

  @variant a, b:c {
    background: red;

    @variant d, e:f {
      background: blue;
    }
  }
}
```
This conceptually translates into the much more verbose version today:
```css
.foo {
  background: black;

  @variant a {
    background: red;

    @variant d {
      background: blue;
    }

    @variant e {
      @variant f {
        background: blue;
      }
    }
  }

  @variant b {
    @variant c {
      background: red;

      @variant d {
        background: blue;
      }

      @variant e {
        @variant f {
          background: blue;
        }
      }
    }
  }
}
```

The biggest downside is that this could potentially easily balloon your
CSS file size if you're not careful. Because with this, it's pretty easy
to add one more variant that introduces a lot of duplicated CSS.

This feature is completely backwards compatible, you can still nest your
`@variant` calls yourself if you want, and combine them with these
features if you want.

This is also a continuation of #19526 and #19884, but for some reason I
don't have push rights, so I'm creating this new PR instead. I did keep
the original commits of those PRs so these contributors are still
properly marked as contributors.
<img width="808" height="135" alt="image"
src="https://github.com/user-attachments/assets/bee334ab-39d7-4d4d-a48f-afa2253cf17b"
/>
<img width="349" height="75" alt="image"
src="https://github.com/user-attachments/assets/fb68906c-db74-43f7-83e4-918ad3d4a036"
/>


Closes: #19526
Closes: #19884

## Test plan

1. Added a bunch of new tests to verify this new behavior
2. Added tests that compare the short (new) version, and the long (old)
version
3. Added a sourcemap related test to ensure that the src and dst
locations are correct
4. Existing tests still pass

---------

Co-authored-by: orteth01 <tortega128@gmail.com>
Co-authored-by: Ray Knight <array.knight+github@gmail.com>
This commit is contained in:
Robin Malfait 2026-04-30 12:19:44 +02:00 • committed by GitHub
parent 6cf1af26b3
commit e1201bc6e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 482 additions and 11 deletions

View file

@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- _Experimental_: add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901))
- Allow using `@variant` with stacked variants (e.g. `@variant hover:focus { … }`) ([#19996](https://github.com/tailwindlabs/tailwindcss/pull/19996))
- Allow using `@variant` with compound variants (e.g. `@variant hover, focus { … }`) ([#19996](https://github.com/tailwindlabs/tailwindcss/pull/19996))
### Fixed

View file

@ -2,6 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import { describe, expect, it, test } from 'vitest'
import { compile, Features, Polyfills } from '.'
import { cartesian } from './cartesian'
import type { PluginAPI } from './compat/plugin-api'
import plugin from './plugin'
import { compileCss, optimizeCss, run } from './test-utils/run'
@ -5459,6 +5460,427 @@ describe('@variant', () => {
`)
})
describe('comma-separated `@variant` rules', () => {
it('should be possible to use comma-separated `@variant` rules', async () => {
await expect(
compileCss(css`
.btn {
background: black;
@variant hover, focus {
background: red;
}
}
@tailwind utilities;
`),
).resolves.toMatchInlineSnapshot(`
".btn {
background: #000;
}
@media (hover: hover) {
.btn:hover {
background: red;
}
}
.btn:focus {
background: red;
}"
`)
expect(
await compileCss(css`
.btn {
background: black;
@variant hover, focus {
background: red;
}
}
@tailwind utilities;
`),
).toEqual(
await compileCss(css`
.btn {
background: black;
@variant hover {
background: red;
}
@variant focus {
background: red;
}
}
@tailwind utilities;
`),
)
})
it.each(
Array.from(
cartesian(
['', ' ', ' ', '\t', '\t\t'], // Before
['', ' ', ' ', '\t', '\t\t'], // After
),
),
)(
"should handle optional whitespace ('%s', '%s') between `@variant` variants",
async (before, after) => {
await expect(
compileCss(css`
.btn {
background: black;
@variant hover${before},${after}focus {
background: red;
}
}
@tailwind utilities;
`),
).resolves.toMatchInlineSnapshot(`
".btn {
background: #000;
}
@media (hover: hover) {
.btn:hover {
background: red;
}
}
.btn:focus {
background: red;
}"
`)
},
)
it('should handle variants containing a `,` inside', async () => {
await expect(
compileCss(css`
.btn {
background: black;
@variant [&:is(:hover,:focus)], disabled {
background: red;
}
}
@tailwind utilities;
`),
).resolves.toMatchInlineSnapshot(`
".btn {
background: #000;
}
.btn:is(:hover, :focus), .btn:disabled {
background: red;
}"
`)
})
it('should handle nested comma-separated variants', async () => {
await expect(
compileCss(css`
.btn {
background: black;
@variant hover, focus {
background: red;
@variant active, disabled {
background: blue;
}
}
}
@tailwind utilities;
`),
).resolves.toMatchInlineSnapshot(`
".btn {
background: #000;
}
@media (hover: hover) {
.btn:hover {
background: red;
}
.btn:hover:active, .btn:hover:disabled {
background: #00f;
}
}
.btn:focus {
background: red;
}
.btn:focus:active, .btn:focus:disabled {
background: #00f;
}"
`)
expect(
await compileCss(css`
.btn {
background: black;
@variant hover, focus {
background: red;
@variant active, disabled {
background: blue;
}
}
}
@tailwind utilities;
`),
).toEqual(
await compileCss(css`
.btn {
background: black;
@variant hover {
background: red;
@variant active {
background: blue;
}
@variant disabled {
background: blue;
}
}
@variant focus {
background: red;
@variant active {
background: blue;
}
@variant disabled {
background: blue;
}
}
}
@tailwind utilities;
`),
)
})
it('should error on invalid variants (trailing comma)', async () => {
await expect(
compileCss(css`
.btn {
background: black;
@variant hover,focus, {
background: red;
}
}
@tailwind utilities;
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Cannot use \`@variant\` with empty variant]`,
)
})
it('should error on invalid variants (double comma)', async () => {
await expect(
compileCss(css`
.btn {
background: black;
@variant hover,,focus {
background: red;
}
}
@tailwind utilities;
`),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: Cannot use \`@variant\` with empty variant]`,
)
})
})
describe('stacked `@variant` rules', () => {
it('should handle stacked variants', async () => {
await expect(
compileCss(css`
.btn {
background: black;
@variant hover:focus {
background: red;
}
}
@tailwind utilities;
`),
).resolves.toMatchInlineSnapshot(`
".btn {
background: #000;
}
@media (hover: hover) {
.btn:hover:focus {
background: red;
}
}"
`)
})
it('should handle stacked variants & comma-separated variants', async () => {
await expect(
compileCss(css`
.btn {
background: black;
@variant hover:focus, disabled {
background: red;
}
}
@tailwind utilities;
`),
).resolves.toMatchInlineSnapshot(`
".btn {
background: #000;
}
@media (hover: hover) {
.btn:hover:focus {
background: red;
}
}
.btn:disabled {
background: red;
}"
`)
})
it('should handle variants containing a `:` inside', async () => {
await expect(
compileCss(css`
.btn {
background: black;
@variant [&:is(:hover,:focus)]:disabled, aria-disabled:hover {
background: red;
}
}
@tailwind utilities;
`),
).resolves.toMatchInlineSnapshot(`
".btn {
background: #000;
}
.btn:is(:hover, :focus):disabled {
background: red;
}
@media (hover: hover) {
.btn[aria-disabled="true"]:hover {
background: red;
}
}"
`)
})
})
it('should be possible to use compound and stacked variants in `@variant`', async () => {
await expect(
compileCss(css`
.btn {
background: black;
@variant data-a, data-b:data-c {
background: red;
@variant data-d, data-e:data-f {
background: blue;
}
}
}
@tailwind utilities;
`),
).resolves.toMatchInlineSnapshot(`
".btn {
background: #000;
}
.btn[data-a] {
background: red;
}
.btn[data-a][data-d], .btn[data-a][data-e][data-f] {
background: #00f;
}
.btn[data-b][data-c] {
background: red;
}
.btn[data-b][data-c][data-d], .btn[data-b][data-c][data-e][data-f] {
background: #00f;
}"
`)
expect(
await compileCss(css`
.btn {
background: black;
@variant data-a, data-b:data-c {
background: red;
@variant data-d, data-e:data-f {
background: blue;
}
}
}
@tailwind utilities;
`),
).toEqual(
await compileCss(css`
.btn {
background: black;
@variant data-a {
background: red;
@variant data-d {
background: blue;
}
@variant data-e {
@variant data-f {
background: blue;
}
}
}
@variant data-b {
@variant data-c {
background: red;
@variant data-d {
background: blue;
}
@variant data-e {
@variant data-f {
background: blue;
}
}
}
}
}
@tailwind utilities;
`),
)
})
it('should be possible to use `@variant` with a funky looking variants', async () => {
await expect(
compileCss(

View file

@ -395,6 +395,31 @@ test('@apply generates source maps', async ({ expect }) => {
])
})
test('@variant generates source maps', async ({ expect }) => {
let { sources, annotations } = await run({
input: css`
.foo {
@variant hover {
color: red;
}
@variant focus:disabled, hover:aria-expanded {
color: blue;
}
}
`,
})
expect(sources).toEqual(['input.css'])
expect(annotations).toEqual([
'input.css: 1:0-5 <- 1:0-5',
'input.css: 4:6-16 <- 3:4-14',
'input.css: 9:6-17 <- 7:4-15',
'input.css: 15:8-19 <- 7:4-15',
])
})
test('license comments preserve source locations', async ({ expect }) => {
let { sources, annotations } = await run({
input: `/*! some comment */`,

View file

@ -1212,24 +1212,46 @@ export function substituteAtVariant(ast: AstNode[], designSystem: DesignSystem):
walk(ast, (variantNode) => {
if (variantNode.kind !== 'at-rule' || variantNode.name !== '@variant') return
// Starting with the `&` rule node
let node = styleRule('&', variantNode.nodes)
let nodes: AstNode[] = []
let compoundVariants = segment(variantNode.params, ',')
for (let [idx, compoundVariant] of compoundVariants.entries()) {
// Starting with the `&` rule node
//
// Only clone the nodes when we have multiple compound variants to deal
// with. The last one can use the original nodes. We do need unique AST
// nodes for sourcemap `dst` location information.
let node = styleRule(
'&',
idx === compoundVariants.length - 1
? variantNode.nodes
: variantNode.nodes.map(cloneAstNode),
)
let variant = variantNode.params
let stackedVariants = segment(compoundVariant, ':')
for (let i = stackedVariants.length - 1; i >= 0; --i) {
let variant = stackedVariants[i].trim()
let variantAst = designSystem.parseVariant(variant)
if (variantAst === null) {
throw new Error(`Cannot use \`@variant\` with unknown variant: ${variant}`)
}
if (!variant) {
throw new Error(`Cannot use \`@variant\` with empty variant`)
}
let result = applyVariant(node, variantAst, designSystem.variants)
if (result === null) {
throw new Error(`Cannot use \`@variant\` with variant: ${variant}`)
let variantAst = designSystem.parseVariant(variant)
if (variantAst === null) {
throw new Error(`Cannot use \`@variant\` with unknown variant: ${variant}`)
}
let result = applyVariant(node, variantAst, designSystem.variants)
if (result === null) {
throw new Error(`Cannot use \`@variant\` with variant: ${variant}`)
}
}
nodes.push(node)
}
// Update the variant at-rule node, to be the `&` rule node
features |= Features.Variants
return WalkAction.Replace(node)
return WalkAction.Replace(nodes)
})
return features
}