From 451de1b956b5e41efaed8ec1b9687abca680f761 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 9 Dec 2025 10:46:14 -0500 Subject: [PATCH 01/10] Tweak code style --- packages/tailwindcss/src/apply.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/apply.ts b/packages/tailwindcss/src/apply.ts index c7bcc5e28801..36f882b673fd 100644 --- a/packages/tailwindcss/src/apply.ts +++ b/packages/tailwindcss/src/apply.ts @@ -166,7 +166,10 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) { let offset = 0 for (let [idx, part] of parts.entries()) { - if (idx % 2 === 0) candidateOffsets[part] = offset + if (idx % 2 === 0) { + candidateOffsets[part] = offset + } + offset += part.length } From 2e9d9881a56dc257eb1d15484065756e8136fa76 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 9 Dec 2025 10:46:20 -0500 Subject: [PATCH 02/10] Handle CSS mixins inside `@apply` --- packages/tailwindcss/src/apply.ts | 28 +++++++++++++ packages/tailwindcss/src/ast.ts | 3 +- packages/tailwindcss/src/index.test.ts | 54 ++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/apply.ts b/packages/tailwindcss/src/apply.ts index 36f882b673fd..60d430731709 100644 --- a/packages/tailwindcss/src/apply.ts +++ b/packages/tailwindcss/src/apply.ts @@ -163,16 +163,44 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) { let parts = child.params.split(/(\s+)/g) let candidateOffsets: Record = {} + let normalIdents: string[] = [] + let dashedIdents: string[] = [] let offset = 0 for (let [idx, part] of parts.entries()) { if (idx % 2 === 0) { + if (part[0] === '-' && part[1] === '-') { + dashedIdents.push(part) + } else { + normalIdents.push(part) + } + candidateOffsets[part] = offset } offset += part.length } + if (dashedIdents.length) { + // If we have an `@apply` that only consists of dashed idents then the + // user is intending to use a CSS mixin: + // https://drafts.csswg.org/css-mixins-1/#apply-rule + // + // These are not considered utilities and need to be emitted literally. + if (normalIdents.length === 0) return WalkAction.Skip + + // If we find a dashed ident *here* it means that someone is trying + // to use mixins and our `@apply` behavior together. + // + // This is invalid and the rules must be written separately. Let the + // user know they need to move them into a separate rule. + let list = dashedIdents.join(' ') + + throw new Error( + `You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply ${list}\` into a separate rule.`, + ) + } + // Replace the `@apply` rule with the actual utility classes { // Parse the candidates to an AST that we can replace the `@apply` rule diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index 77fa742e90c6..eea9eb5c0351 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -403,7 +403,8 @@ export function optimizeAst( copy.name === '@charset' || copy.name === '@custom-media' || copy.name === '@namespace' || - copy.name === '@import' + copy.name === '@import' || + copy.name === '@apply' ) { parent.push(copy) } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 0535c98caf41..12a6b2e69472 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -886,6 +886,60 @@ describe('@apply', () => { " `) }) + + it('should be usable with CSS mixins', async () => { + let compiler = await compile(css` + .foo { + /* Utility usage */ + @apply underline; + + /* CSS mixin usage */ + @apply --my-mixin-1; + @apply --my-mixin-1(); + @apply --my-mixin-1 --my-mixin-2; + @apply --my-mixin-1() --my-mixin-2(); + @apply --my-mixin-3 { + color: red; + } + } + `) + + expect(compiler.build([])).toMatchInlineSnapshot(` + ".foo { + text-decoration-line: underline; + @apply --my-mixin-1; + @apply --my-mixin-1(); + @apply --my-mixin-1 --my-mixin-2; + @apply --my-mixin-1() --my-mixin-2(); + @apply --my-mixin-3 { + color: red; + } + } + " + `) + }) + + it('should error when trying to use mixins and utilities together', async () => { + await expect( + compile(css` + .foo { + @apply underline --my-mixin-1; + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply --my-mixin-1\` into a separate rule.]`, + ) + + await expect( + compile(css` + .foo { + @apply --my-mixin-1 underline; + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply --my-mixin-1\` into a separate rule.]`, + ) + }) }) describe('arbitrary variants', () => { From 6984206a65f35a96c72328901cda9532df37e17d Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 9 Dec 2025 10:29:11 -0500 Subject: [PATCH 03/10] =?UTF-8?q?Don=E2=80=99t=20allow=20utility-based=20`?= =?UTF-8?q?@apply`=20to=20be=20used=20with=20a=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/tailwindcss/src/apply.ts | 13 +++++++++++++ packages/tailwindcss/src/index.test.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/tailwindcss/src/apply.ts b/packages/tailwindcss/src/apply.ts index 60d430731709..19a2de5f96f7 100644 --- a/packages/tailwindcss/src/apply.ts +++ b/packages/tailwindcss/src/apply.ts @@ -201,6 +201,19 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) { ) } + // If we find a dashed ident *here* it means that someone is trying + // to use mixins and our `@apply` behavior together. + // + // This is considered invalid usage and we want to inform the user + // as such. + let hasBody = child.nodes.length > 0 + + if (hasBody && normalIdents.length) { + let list = normalIdents.join(' ') + + throw new Error(`The rule \`@apply ${list}\` must not have a body.`) + } + // Replace the `@apply` rule with the actual utility classes { // Parse the candidates to an AST that we can replace the `@apply` rule diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 12a6b2e69472..fc6e99d38ca4 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -940,6 +940,20 @@ describe('@apply', () => { `[Error: You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply --my-mixin-1\` into a separate rule.]`, ) }) + + it('should error when used with a body', async () => { + await expect( + compile(css` + .foo { + @apply underline { + color: red; + } + } + `), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: The rule \`@apply underline\` must not have a body.]`, + ) + }) }) describe('arbitrary variants', () => { From f531ed12e79dac0b02679b8f5ef9fdaf8cf6527c Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 9 Dec 2025 10:59:42 -0500 Subject: [PATCH 04/10] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 306b384266c9..0d56f1d4470c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Correctly handle duplicate CLI arguments ([#19416](https://github.com/tailwindlabs/tailwindcss/pull/19416)) - Don’t emit color-mix fallback rules inside `@keyframes` ([#19419](https://github.com/tailwindlabs/tailwindcss/pull/19419)) - CLI: Don't hang when output is `/dev/stdout` ([#19421](https://github.com/tailwindlabs/tailwindcss/pull/19421)) +- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427)) ## [3.4.19] - 2025-12-10 From 83ea878b1d74bcdd91fc43e4288023425e04f83b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 9 Dec 2025 11:17:09 -0500 Subject: [PATCH 05/10] drop comment --- packages/tailwindcss/src/apply.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/tailwindcss/src/apply.ts b/packages/tailwindcss/src/apply.ts index 19a2de5f96f7..2d5e5b847bbc 100644 --- a/packages/tailwindcss/src/apply.ts +++ b/packages/tailwindcss/src/apply.ts @@ -201,11 +201,6 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) { ) } - // If we find a dashed ident *here* it means that someone is trying - // to use mixins and our `@apply` behavior together. - // - // This is considered invalid usage and we want to inform the user - // as such. let hasBody = child.nodes.length > 0 if (hasBody && normalIdents.length) { From 84a69192613c3920e8576ae718d01cb5d7f1dfd2 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 30 Apr 2026 16:46:08 +0200 Subject: [PATCH 06/10] add `@apply` with mixin to source map tests --- .../src/source-maps/source-map.test.ts | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/tailwindcss/src/source-maps/source-map.test.ts b/packages/tailwindcss/src/source-maps/source-map.test.ts index 5fe0c0e5bede..25399e843898 100644 --- a/packages/tailwindcss/src/source-maps/source-map.test.ts +++ b/packages/tailwindcss/src/source-maps/source-map.test.ts @@ -677,6 +677,7 @@ test('@apply generates source maps', async ({ expect }) => { color: blue; @apply text-[#000] hover:text-[#f00]; @apply underline; + @apply --my-mixin-1 --my-mixin-2(); color: red; } `, @@ -686,29 +687,31 @@ test('@apply generates source maps', async ({ expect }) => { expect(annotations).toMatchInlineSnapshot(` " - output.css | input.css - | - 1 .foo { | 1 .foo { - ^^^^^ A @ 1:0-5 | ^^^^^ A @ 1:0-5 - 2 color: blue; | 2 color: blue; - ^^^^^^^^^^^ B @ 2:2-13 | ^^^^^^^^^^^ B @ 2:2-13 - 3 color: #000; | 3 @apply text-[#000] hover:text-[#f00]; - ^^^^^^^^^^^ C @ 3:2-13 | ^^^^^^^^^^^ C @ 3:9-20 - 4 &:hover { | 3 @apply text-[#000] hover:text-[#f00]; - ^^^^^^^^ D @ 4:2-10 | ^^^^^^^^^^^^^^^^^ D @ 3:21-38 - 5 @media (hover: hover) { | - ^^^^^^^^^^^^^^^^^^^^^^ D @ 5:4-26 | - 6 color: #f00; | - ^^^^^^^^^^^ D @ 6:6-17 | - 7 } | - 8 } | - 9 text-decoration-line: underline; | 4 @apply underline; - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ E @ 9:2-33 | ^^^^^^^^^ E @ 4:9-18 - 10 color: red; | 5 color: red; - ^^^^^^^^^^ F @ 10:2-12 | ^^^^^^^^^^ F @ 5:2-12 - | 6 } - 11 } | - 12 | + output.css | input.css + | + 1 .foo { | 1 .foo { + ^^^^^ A @ 1:0-5 | ^^^^^ A @ 1:0-5 + 2 color: blue; | 2 color: blue; + ^^^^^^^^^^^ B @ 2:2-13 | ^^^^^^^^^^^ B @ 2:2-13 + 3 color: #000; | 3 @apply text-[#000] hover:text-[#f00]; + ^^^^^^^^^^^ C @ 3:2-13 | ^^^^^^^^^^^ C @ 3:9-20 + 4 &:hover { | 3 @apply text-[#000] hover:text-[#f00]; + ^^^^^^^^ D @ 4:2-10 | ^^^^^^^^^^^^^^^^^ D @ 3:21-38 + 5 @media (hover: hover) { | + ^^^^^^^^^^^^^^^^^^^^^^ D @ 5:4-26 | + 6 color: #f00; | + ^^^^^^^^^^^ D @ 6:6-17 | + 7 } | + 8 } | + 9 text-decoration-line: underline; | 4 @apply underline; + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ E @ 9:2-33 | ^^^^^^^^^ E @ 4:9-18 + 10 @apply --my-mixin-1 --my-mixin-2(); | 5 @apply --my-mixin-1 --my-mixin-2(); + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ F @ 10:2-36 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ F @ 5:2-36 + 11 color: red; | 6 color: red; + ^^^^^^^^^^ G @ 11:2-12 | ^^^^^^^^^^ G @ 6:2-12 + | 7 } + 12 } | + 13 | " `) }) From cdcbf2b83e9c592050ad328678e0c51ae84e43a1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 30 Apr 2026 16:53:12 +0200 Subject: [PATCH 07/10] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d56f1d4470c..084028ccbee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove deprecation warnings by using `Module#registerHooks` instead of `Module#register` on Node 26+ ([#20028](https://github.com/tailwindlabs/tailwindcss/pull/20028)) - Canonicalization: don't crash when plugin utilities throw for unsupported values ([#20052](https://github.com/tailwindlabs/tailwindcss/pull/20052)) +- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427)) ## [4.3.0] - 2026-05-08 @@ -151,7 +152,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Correctly handle duplicate CLI arguments ([#19416](https://github.com/tailwindlabs/tailwindcss/pull/19416)) - Don’t emit color-mix fallback rules inside `@keyframes` ([#19419](https://github.com/tailwindlabs/tailwindcss/pull/19419)) - CLI: Don't hang when output is `/dev/stdout` ([#19421](https://github.com/tailwindlabs/tailwindcss/pull/19421)) -- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427)) ## [3.4.19] - 2025-12-10 From e2202b7434d2db81baa82cedaca2202b09c0f2f0 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 30 Apr 2026 17:04:51 +0200 Subject: [PATCH 08/10] update test to also use Lightning CSS --- packages/tailwindcss/src/index.test.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index fc6e99d38ca4..7ffd2aa211f8 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -888,7 +888,7 @@ describe('@apply', () => { }) it('should be usable with CSS mixins', async () => { - let compiler = await compile(css` + let input = css` .foo { /* Utility usage */ @apply underline; @@ -902,8 +902,9 @@ describe('@apply', () => { color: red; } } - `) + ` + let compiler = await compile(input) expect(compiler.build([])).toMatchInlineSnapshot(` ".foo { text-decoration-line: underline; @@ -917,6 +918,26 @@ describe('@apply', () => { } " `) + + // TODO: This output is currently broken because Lightning CSS doesn't + // handle this case correctly yet + expect(await compileCss(input)).toMatchInlineSnapshot(` + ".foo { + text-decoration-line: underline; + } + + @apply --my-mixin-1; + + @apply --my-mixin-1(); + + @apply --my-mixin-1 --my-mixin-2; + + @apply --my-mixin-1() --my-mixin-2(); + + @apply --my-mixin-3 { + color: red; + }" + `) }) it('should error when trying to use mixins and utilities together', async () => { From 28e3e63ee39d23cc1fa6205a1bec2fae37c77129 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 14 May 2026 16:10:04 +0200 Subject: [PATCH 09/10] add comment where we use the compiler directly --- packages/tailwindcss/src/index.test.ts | 31 ++++++++++++++------------ 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 7ffd2aa211f8..d70e0b80cabf 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -904,22 +904,25 @@ describe('@apply', () => { } ` - let compiler = await compile(input) - expect(compiler.build([])).toMatchInlineSnapshot(` - ".foo { - text-decoration-line: underline; - @apply --my-mixin-1; - @apply --my-mixin-1(); - @apply --my-mixin-1 --my-mixin-2; - @apply --my-mixin-1() --my-mixin-2(); - @apply --my-mixin-3 { - color: red; + // TODO: once Lightning CSS properly supports it, then we can drop this section: + { + let compiler = await compile(input) + expect(compiler.build([])).toMatchInlineSnapshot(` + ".foo { + text-decoration-line: underline; + @apply --my-mixin-1; + @apply --my-mixin-1(); + @apply --my-mixin-1 --my-mixin-2; + @apply --my-mixin-1() --my-mixin-2(); + @apply --my-mixin-3 { + color: red; + } } - } - " - `) + " + `) + } - // TODO: This output is currently broken because Lightning CSS doesn't + // TODO: this output is currently broken because Lightning CSS doesn't // handle this case correctly yet expect(await compileCss(input)).toMatchInlineSnapshot(` ".foo { From dcc130da54951f22fdbdf5341d3a6259c151fff1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 14 May 2026 16:17:13 +0200 Subject: [PATCH 10/10] update tests --- packages/tailwindcss/src/index.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index d70e0b80cabf..2a72336fb738 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -925,7 +925,8 @@ describe('@apply', () => { // TODO: this output is currently broken because Lightning CSS doesn't // handle this case correctly yet expect(await compileCss(input)).toMatchInlineSnapshot(` - ".foo { + " + .foo { text-decoration-line: underline; } @@ -939,7 +940,8 @@ describe('@apply', () => { @apply --my-mixin-3 { color: red; - }" + } + " `) })