From e1201bc6e3f663e9769ebb74e68889a26c587da8 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 30 Apr 2026 12:19:44 +0200 Subject: [PATCH] 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:b: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. image image 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 Co-authored-by: Ray Knight --- CHANGELOG.md | 2 + packages/tailwindcss/src/index.test.ts | 422 ++++++++++++++++++ .../src/source-maps/source-map.test.ts | 25 ++ packages/tailwindcss/src/variants.ts | 44 +- 4 files changed, 482 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b13ad86b..3c7025f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index bf1c72c42..b99476558 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -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( diff --git a/packages/tailwindcss/src/source-maps/source-map.test.ts b/packages/tailwindcss/src/source-maps/source-map.test.ts index 02d8a434a..52ccc68ce 100644 --- a/packages/tailwindcss/src/source-maps/source-map.test.ts +++ b/packages/tailwindcss/src/source-maps/source-map.test.ts @@ -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 */`, diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 0b4bcb0c5..76ead2dc9 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -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 }