tailwindlabs/tailwindcss · error · Error
`@theme` blocks must only contain custom properties or `@key
Error message
`@theme` blocks must only contain custom properties or `@keyframes`.
${snippet} What it means
Thrown while walking an `@theme` block when a child node is neither a comment, nor a `--`-prefixed declaration, nor an `@keyframes` at-rule. `@theme` is a token container: it may only hold custom properties (`--*`) and `@keyframes` definitions (which Tailwind re-inserts alongside the theme variables). Any other construct — a selector rule, a plain declaration, a non-keyframes at-rule — is rejected with a CSS snippet of the offending node marked with `>`.
Source
Thrown at packages/tailwindcss/src/index.ts:586
// Collect `@keyframes` rules to re-insert with theme variables later,
// since the `@theme` rule itself will be removed.
if (child.kind === 'at-rule' && child.name === '@keyframes') {
theme.addKeyframes(child)
return WalkAction.Skip
}
if (child.kind === 'comment') return
if (child.kind === 'declaration' && child.property.startsWith('--')) {
theme.add(unescape(child.property), child.value ?? '', themeOptions, child.src)
return
}
let snippet = toCss([atRule(node.name, node.params, [child])])
.split('\n')
.map((line, idx, all) => `${idx === 0 || idx >= all.length - 2 ? ' ' : '>'} ${line}`)
.join('\n')
throw new Error(
`\`@theme\` blocks must only contain custom properties or \`@keyframes\`.\n\n${snippet}`,
)
})
// Keep a reference to the first `@theme` rule to update with the full
// theme later, and delete any other `@theme` rules.
if (!firstThemeRule) {
firstThemeRule = styleRule(':root, :host', [])
firstThemeRule.src = node.src
return WalkAction.ReplaceSkip(firstThemeRule)
} else {
return WalkAction.ReplaceSkip([])
}
}
})
let designSystem = buildDesignSystem(theme, utilitiesNode?.src)
View on GitHub (pinned to 16e94cbf7f)
Solutions
- Move non-token rules out of `@theme` into `@layer base` or the top level of the stylesheet.
- Ensure every declaration inside `@theme` begins with `--` (e.g. `--color-brand: #007;`).
- Keep only `@keyframes` at-rules alongside the custom properties — other at-rules are not permitted.
Example fix
/* before */
@theme {
--color-brand: #007;
body { margin: 0; }
}
/* after */
@theme {
--color-brand: #007;
}
@layer base {
body { margin: 0; }
} Defensive patterns
Strategy: validation
Validate before calling
// Verify every @theme child is a --declaration, @keyframes, or comment.
import postcss from 'postcss'
function validateThemeChildren(css: string): string[] {
const errors: string[] = []
postcss.parse(css).walkAtRules('@theme', (rule) => {
rule.each((node) => {
if (node.type === 'comment') return
if (node.type === 'decl' && node.prop.startsWith('--')) return
if (node.type === 'atrule' && (node as postcss.AtRule).name === 'keyframes') return
errors.push(`Invalid node in @theme: ${node.type} ${(node as any).selector || (node as any).prop || (node as any).name}`)
})
})
return errors
} Prevention
- Keep `@theme` blocks for tokens only — move base/component CSS to `@layer base` or the top level.
- Prefix every declaration inside `@theme` with `--`.
- Lint `@theme` contents in CI to catch misplaced rules.
When it happens
Trigger: Putting a selector rule (`body { ... }`), a non-custom property (`color: red;`), or a non-keyframes at-rule (`@media ...`) inside `@theme { ... }`.
Common situations: Treating `@theme` like a generic CSS scope; refactoring a base stylesheet and dropping rules into the wrong block; migrating v3 `@layer base` content into `@theme`.
Related errors
- `@source` cannot have a body.
- `@custom-variant ${name}` cannot have both a selector and a
- The prefix "${resolvedConfig.prefix}" is invalid. Prefixes m
- `addVariant('${name}')` defines an invalid variant name. Var
- `matchUtilities({ '${name}' : … })` defines an invalid utili
AI-assisted analysis of tailwindlabs/tailwindcss@16e94cbf7f (2026-08-12).
Data as JSON: /api/errors/411778b54340e6f7.
Report an issue: GitHub.