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

  1. Move non-token rules out of `@theme` into `@layer base` or the top level of the stylesheet.
  2. Ensure every declaration inside `@theme` begins with `--` (e.g. `--color-brand: #007;`).
  3. 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

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


AI-assisted analysis of tailwindlabs/tailwindcss@16e94cbf7f (2026-08-12). Data as JSON: /api/errors/411778b54340e6f7. Report an issue: GitHub.