Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgrade: don’t migrate inline `style` attributes ([#19918](https://github.com/tailwindlabs/tailwindcss/pull/19918))
- Allow multiple `@utility` definitions with the same name but different value types ([#19777](https://github.com/tailwindlabs/tailwindcss/pull/19777))
- Export missing `PluginWithConfig` type from `tailwindcss/plugin` to fix errors when inferring plugin config types ([#19707](https://github.com/tailwindlabs/tailwindcss/pull/19707))
- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427))
- Ensure `start` and `end` legacy utilities without values do not generate CSS ([#20003](https://github.com/tailwindlabs/tailwindcss/pull/20003))

## [4.2.4] - 2026-04-21
Expand Down
41 changes: 40 additions & 1 deletion packages/tailwindcss/src/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,52 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {

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

let offset = 0
for (let [idx, part] of parts.entries()) {
if (idx % 2 === 0) candidateOffsets[part] = offset
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.`,
)
}

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
Expand Down
3 changes: 2 additions & 1 deletion packages/tailwindcss/src/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
89 changes: 89 additions & 0 deletions packages/tailwindcss/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,95 @@ describe('@apply', () => {
}"
`)
})

it('should be usable with CSS mixins', async () => {
let input = 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;
}
}
`

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
// 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 () => {
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.]`,
)
})

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', () => {
Expand Down
49 changes: 26 additions & 23 deletions packages/tailwindcss/src/source-maps/source-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
`,
Expand All @@ -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 |
"
`)
})
Expand Down