From d7048e5663ac697b515a96a0504d709d05b32600 Mon Sep 17 00:00:00 2001 From: orteth01 Date: Mon, 5 Jan 2026 21:02:35 -0600 Subject: [PATCH 1/5] 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/5] 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/5] 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 () => { From f93c53a8363e9a83b0afbf8945c373ff290c9769 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Tue, 31 Mar 2026 14:28:40 -0700 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20support=20compound?= =?UTF-8?q?=20variant=20selectors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tailwindcss/src/index.test.ts | 102 +++++++++++++++++++++++++ packages/tailwindcss/src/variants.ts | 28 ++++--- 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 9c27ba81c4e8..84959dc09a20 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -5463,6 +5463,46 @@ describe('@variant', () => { `) }) + it('should handle missing 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 handle missing variants (gap in the middle)', 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 handle nested comma-separated variants', async () => { await expect( compileCss( @@ -5508,6 +5548,68 @@ describe('@variant', () => { }) }) + describe('compound `@variant` rules', () => { + it('should handle compound 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 compound 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 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 884125a1b565..c71817e72ca0 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -1212,20 +1212,30 @@ export function substituteAtVariant(ast: AstNode[], designSystem: DesignSystem): walk(ast, (variantNode) => { if (variantNode.kind !== 'at-rule' || variantNode.name !== '@variant') return - let variants = segment(variantNode.params, ',').map((variant) => variant.trim()) + let selectors = segment(variantNode.params, ',').map((variants: string) => + segment(variants, ':') + .map((variant) => variant.trim()) + .reverse(), + ) let nodes: AstNode[] = [] - for (let variant of variants) { + for (let variants of selectors) { // Starting with the `&` rule node let node = styleRule('&', variantNode.nodes.map(cloneAstNode)) - let variantAst = designSystem.parseVariant(variant) - if (variantAst === null) { - throw new Error(`Cannot use \`@variant\` with unknown variant: ${variant}`) - } + for (let variant of variants) { + 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) From 1baab109bfe41d5d1ada3fb7bb19b97d29f33b29 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Wed, 1 Apr 2026 10:39:23 -0700 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20=F0=9F=92=A1=20rename=20compoun?= =?UTF-8?q?d=20to=20stacked=20for=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tailwindcss/src/index.test.ts | 6 +++--- packages/tailwindcss/src/variants.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index badc21ce6b90..7757855c5a21 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -5673,8 +5673,8 @@ describe('@variant', () => { }) }) - describe('compound `@variant` rules', () => { - it('should handle compound variants', async () => { + describe('stacked `@variant` rules', () => { + it('should handle stacked variants', async () => { await expect( compileCss( css` @@ -5702,7 +5702,7 @@ describe('@variant', () => { `) }) - it('should handle compound variants & comma-separated variants', async () => { + it('should handle stacked variants & comma-separated variants', async () => { await expect( compileCss( css` diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 81e29a65b34a..6f69e230263f 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -1212,13 +1212,13 @@ export function substituteAtVariant(ast: AstNode[], designSystem: DesignSystem): walk(ast, (variantNode) => { if (variantNode.kind !== 'at-rule' || variantNode.name !== '@variant') return - let selectors = segment(variantNode.params, ',').map((variants: string) => + let stacks = segment(variantNode.params, ',').map((variants: string) => segment(variants, ':') .map((variant) => variant.trim()) .reverse(), ) let nodes: AstNode[] = [] - for (let variants of selectors) { + for (let variants of stacks) { // Starting with the `&` rule node let node = styleRule('&', variantNode.nodes.map(cloneAstNode))