Skip to content

Commit 460a008

Browse files
v4: Allow @apply to be used with CSS mixins (#19427)
The CSS Custom Functions and Mixins spec [is using `@apply` with dashed idents](https://drafts.csswg.org/css-mixins-1/#apply-rule) for native mixin support. We shouldn't attempt to treat these as utilities and try to compile them since they'll fail. There one intentional limitation with regards to mixins and our use of `@apply`: We do not allow users to mix utilities and mixins in the same at-rule. In other words, all of the following `@apply` rules are invalid: ```css .foo { /* Invalid because the rules contain both mixins and utilities */ @apply --my-mixin underline; @apply --my-mixin() underline; @apply underline --my-mixin; @apply underline --my-mixin(); } ``` Aside: Lightning CSS does not yet support this syntax so the results of a production build won't produce the correct code but we'll at least handle these correctly in Tailwind CSS itself. Fixes #19422 --------- Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
1 parent 0db226a commit 460a008

5 files changed

Lines changed: 163 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Remove deprecation warnings by using `Module#registerHooks` instead of `Module#register` on Node 26+ ([#20028](https://github.com/tailwindlabs/tailwindcss/pull/20028))
1313
- Canonicalization: don't crash when plugin utilities throw for unsupported values ([#20052](https://github.com/tailwindlabs/tailwindcss/pull/20052))
14+
- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427))
1415

1516
## [4.3.0] - 2026-05-08
1617

packages/tailwindcss/src/apply.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,52 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
163163

164164
let parts = child.params.split(/(\s+)/g)
165165
let candidateOffsets: Record<string, number> = {}
166+
let normalIdents: string[] = []
167+
let dashedIdents: string[] = []
166168

167169
let offset = 0
168170
for (let [idx, part] of parts.entries()) {
169-
if (idx % 2 === 0) candidateOffsets[part] = offset
171+
if (idx % 2 === 0) {
172+
if (part[0] === '-' && part[1] === '-') {
173+
dashedIdents.push(part)
174+
} else {
175+
normalIdents.push(part)
176+
}
177+
178+
candidateOffsets[part] = offset
179+
}
180+
170181
offset += part.length
171182
}
172183

184+
if (dashedIdents.length) {
185+
// If we have an `@apply` that only consists of dashed idents then the
186+
// user is intending to use a CSS mixin:
187+
// https://drafts.csswg.org/css-mixins-1/#apply-rule
188+
//
189+
// These are not considered utilities and need to be emitted literally.
190+
if (normalIdents.length === 0) return WalkAction.Skip
191+
192+
// If we find a dashed ident *here* it means that someone is trying
193+
// to use mixins and our `@apply` behavior together.
194+
//
195+
// This is invalid and the rules must be written separately. Let the
196+
// user know they need to move them into a separate rule.
197+
let list = dashedIdents.join(' ')
198+
199+
throw new Error(
200+
`You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply ${list}\` into a separate rule.`,
201+
)
202+
}
203+
204+
let hasBody = child.nodes.length > 0
205+
206+
if (hasBody && normalIdents.length) {
207+
let list = normalIdents.join(' ')
208+
209+
throw new Error(`The rule \`@apply ${list}\` must not have a body.`)
210+
}
211+
173212
// Replace the `@apply` rule with the actual utility classes
174213
{
175214
// Parse the candidates to an AST that we can replace the `@apply` rule

packages/tailwindcss/src/ast.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,8 @@ export function optimizeAst(
403403
copy.name === '@charset' ||
404404
copy.name === '@custom-media' ||
405405
copy.name === '@namespace' ||
406-
copy.name === '@import'
406+
copy.name === '@import' ||
407+
copy.name === '@apply'
407408
) {
408409
parent.push(copy)
409410
}

packages/tailwindcss/src/index.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,100 @@ describe('@apply', () => {
886886
"
887887
`)
888888
})
889+
890+
it('should be usable with CSS mixins', async () => {
891+
let input = css`
892+
.foo {
893+
/* Utility usage */
894+
@apply underline;
895+
896+
/* CSS mixin usage */
897+
@apply --my-mixin-1;
898+
@apply --my-mixin-1();
899+
@apply --my-mixin-1 --my-mixin-2;
900+
@apply --my-mixin-1() --my-mixin-2();
901+
@apply --my-mixin-3 {
902+
color: red;
903+
}
904+
}
905+
`
906+
907+
// TODO: once Lightning CSS properly supports it, then we can drop this section:
908+
{
909+
let compiler = await compile(input)
910+
expect(compiler.build([])).toMatchInlineSnapshot(`
911+
".foo {
912+
text-decoration-line: underline;
913+
@apply --my-mixin-1;
914+
@apply --my-mixin-1();
915+
@apply --my-mixin-1 --my-mixin-2;
916+
@apply --my-mixin-1() --my-mixin-2();
917+
@apply --my-mixin-3 {
918+
color: red;
919+
}
920+
}
921+
"
922+
`)
923+
}
924+
925+
// TODO: this output is currently broken because Lightning CSS doesn't
926+
// handle this case correctly yet
927+
expect(await compileCss(input)).toMatchInlineSnapshot(`
928+
"
929+
.foo {
930+
text-decoration-line: underline;
931+
}
932+
933+
@apply --my-mixin-1;
934+
935+
@apply --my-mixin-1();
936+
937+
@apply --my-mixin-1 --my-mixin-2;
938+
939+
@apply --my-mixin-1() --my-mixin-2();
940+
941+
@apply --my-mixin-3 {
942+
color: red;
943+
}
944+
"
945+
`)
946+
})
947+
948+
it('should error when trying to use mixins and utilities together', async () => {
949+
await expect(
950+
compile(css`
951+
.foo {
952+
@apply underline --my-mixin-1;
953+
}
954+
`),
955+
).rejects.toThrowErrorMatchingInlineSnapshot(
956+
`[Error: You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply --my-mixin-1\` into a separate rule.]`,
957+
)
958+
959+
await expect(
960+
compile(css`
961+
.foo {
962+
@apply --my-mixin-1 underline;
963+
}
964+
`),
965+
).rejects.toThrowErrorMatchingInlineSnapshot(
966+
`[Error: You cannot use \`@apply\` with both mixins and utilities. Please move \`@apply --my-mixin-1\` into a separate rule.]`,
967+
)
968+
})
969+
970+
it('should error when used with a body', async () => {
971+
await expect(
972+
compile(css`
973+
.foo {
974+
@apply underline {
975+
color: red;
976+
}
977+
}
978+
`),
979+
).rejects.toThrowErrorMatchingInlineSnapshot(
980+
`[Error: The rule \`@apply underline\` must not have a body.]`,
981+
)
982+
})
889983
})
890984

891985
describe('arbitrary variants', () => {

packages/tailwindcss/src/source-maps/source-map.test.ts

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,7 @@ test('@apply generates source maps', async ({ expect }) => {
677677
color: blue;
678678
@apply text-[#000] hover:text-[#f00];
679679
@apply underline;
680+
@apply --my-mixin-1 --my-mixin-2();
680681
color: red;
681682
}
682683
`,
@@ -686,29 +687,31 @@ test('@apply generates source maps', async ({ expect }) => {
686687

687688
expect(annotations).toMatchInlineSnapshot(`
688689
"
689-
output.css | input.css
690-
|
691-
1 .foo { | 1 .foo {
692-
^^^^^ A @ 1:0-5 | ^^^^^ A @ 1:0-5
693-
2 color: blue; | 2 color: blue;
694-
^^^^^^^^^^^ B @ 2:2-13 | ^^^^^^^^^^^ B @ 2:2-13
695-
3 color: #000; | 3 @apply text-[#000] hover:text-[#f00];
696-
^^^^^^^^^^^ C @ 3:2-13 | ^^^^^^^^^^^ C @ 3:9-20
697-
4 &:hover { | 3 @apply text-[#000] hover:text-[#f00];
698-
^^^^^^^^ D @ 4:2-10 | ^^^^^^^^^^^^^^^^^ D @ 3:21-38
699-
5 @media (hover: hover) { |
700-
^^^^^^^^^^^^^^^^^^^^^^ D @ 5:4-26 |
701-
6 color: #f00; |
702-
^^^^^^^^^^^ D @ 6:6-17 |
703-
7 } |
704-
8 } |
705-
9 text-decoration-line: underline; | 4 @apply underline;
706-
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ E @ 9:2-33 | ^^^^^^^^^ E @ 4:9-18
707-
10 color: red; | 5 color: red;
708-
^^^^^^^^^^ F @ 10:2-12 | ^^^^^^^^^^ F @ 5:2-12
709-
| 6 }
710-
11 } |
711-
12 |
690+
output.css | input.css
691+
|
692+
1 .foo { | 1 .foo {
693+
^^^^^ A @ 1:0-5 | ^^^^^ A @ 1:0-5
694+
2 color: blue; | 2 color: blue;
695+
^^^^^^^^^^^ B @ 2:2-13 | ^^^^^^^^^^^ B @ 2:2-13
696+
3 color: #000; | 3 @apply text-[#000] hover:text-[#f00];
697+
^^^^^^^^^^^ C @ 3:2-13 | ^^^^^^^^^^^ C @ 3:9-20
698+
4 &:hover { | 3 @apply text-[#000] hover:text-[#f00];
699+
^^^^^^^^ D @ 4:2-10 | ^^^^^^^^^^^^^^^^^ D @ 3:21-38
700+
5 @media (hover: hover) { |
701+
^^^^^^^^^^^^^^^^^^^^^^ D @ 5:4-26 |
702+
6 color: #f00; |
703+
^^^^^^^^^^^ D @ 6:6-17 |
704+
7 } |
705+
8 } |
706+
9 text-decoration-line: underline; | 4 @apply underline;
707+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ E @ 9:2-33 | ^^^^^^^^^ E @ 4:9-18
708+
10 @apply --my-mixin-1 --my-mixin-2(); | 5 @apply --my-mixin-1 --my-mixin-2();
709+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ F @ 10:2-36 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ F @ 5:2-36
710+
11 color: red; | 6 color: red;
711+
^^^^^^^^^^ G @ 11:2-12 | ^^^^^^^^^^ G @ 6:2-12
712+
| 7 }
713+
12 } |
714+
13 |
712715
"
713716
`)
714717
})

0 commit comments

Comments
 (0)