From d7048e5663ac697b515a96a0504d709d05b32600 Mon Sep 17 00:00:00 2001 From: orteth01 Date: Mon, 5 Jan 2026 21:02:35 -0600 Subject: [PATCH 1/3] feat: allow using @variant with multiple, comma-separated variants --- packages/tailwindcss/src/index.test.ts | 32 ++++++++++++++++++++++++++ packages/tailwindcss/src/variants.ts | 28 ++++++++++++---------- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 2d6ab8daf6f7..4122ff91fddd 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -5299,6 +5299,38 @@ describe('@variant', () => { `) }) + 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; + }" + `) + }) + it('should be possible to use `@variant` with a funky looking variants', async () => { await expect( compileCss( diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 82a2b8592cfe..cd20b1289945 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -1212,24 +1212,28 @@ 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 variant = variantNode.params + let variants = segment(variantNode.params, ',').map((variant) => variant.trim()) + let nodes: AstNode[] = [] + for (let variant of variants) { + // Starting with the `&` rule node + let node = styleRule('&', variantNode.nodes) + + let variantAst = designSystem.parseVariant(variant) + if (variantAst === null) { + throw new Error(`Cannot use \`@variant\` with unknown 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}`) + } - 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 } From a409bba58d8ab729dad1e842a0059efb1dd6d543 Mon Sep 17 00:00:00 2001 From: orteth01 Date: Mon, 5 Jan 2026 21:12:10 -0600 Subject: [PATCH 2/3] coderabbit suggestion: clone nodes for each iteration --- packages/tailwindcss/src/variants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index cd20b1289945..884125a1b565 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -1216,7 +1216,7 @@ export function substituteAtVariant(ast: AstNode[], designSystem: DesignSystem): let nodes: AstNode[] = [] for (let variant of variants) { // Starting with the `&` rule node - let node = styleRule('&', variantNode.nodes) + let node = styleRule('&', variantNode.nodes.map(cloneAstNode)) let variantAst = designSystem.parseVariant(variant) if (variantAst === null) { From 10d59b2def2735cbfad2dae5eac73209f32001e3 Mon Sep 17 00:00:00 2001 From: orteth01 Date: Mon, 5 Jan 2026 21:22:03 -0600 Subject: [PATCH 3/3] more tests --- packages/tailwindcss/src/index.test.ts | 188 ++++++++++++++++++++++--- 1 file changed, 165 insertions(+), 23 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 4122ff91fddd..2bbe9c669900 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -5299,36 +5299,178 @@ describe('@variant', () => { `) }) - it('should be possible to use comma-separated `@variant` rules', async () => { - await expect( - compileCss( - css` - .btn { - background: black; + 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; + } - @variant hover, focus { - background: red; + @media (hover: hover) { + .btn:hover { + background: red; + } + } + + .btn:focus { + background: red; + }" + `) + }) + + it('should handle three or more variants', async () => { + await expect( + compileCss( + css` + .btn { + background: black; + + @variant hover, focus, active { + background: red; + } } + @tailwind utilities; + `, + [], + ), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; + } + + @media (hover: hover) { + .btn:hover { + background: red; } - @tailwind utilities; - `, - [], - ), - ).resolves.toMatchInlineSnapshot(` - ".btn { - background: #000; - } + } - @media (hover: hover) { - .btn:hover { + .btn:focus, .btn:active { background: red; + }" + `) + }) + + it('should handle whitespace variations (no space after comma)', async () => { + await expect( + compileCss( + css` + .btn { + background: black; + + @variant hover,focus { + background: red; + } + } + @tailwind utilities; + `, + [], + ), + ).resolves.toMatchInlineSnapshot(` + ".btn { + background: #000; } - } - .btn:focus { - background: red; - }" - `) + @media (hover: hover) { + .btn:hover { + background: red; + } + } + + .btn:focus { + background: red; + }" + `) + }) + + it('should handle whitespace variations (space before and after comma)', 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; + }" + `) + }) + + 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; + }" + `) + }) }) it('should be possible to use `@variant` with a funky looking variants', async () => {