Skip to content

Reference · craft

Tailwind v4 (4.3.x): silent failures and the correct form

craft/references/tailwind-v4.md95 linesupdated 16 Sept 2026

Tailwind v4 never errors on a class it doesn't know. The page just looks slightly wrong. void lint compiles the real CSS entry and diffs used classes against emitted CSS; run it after every styling pass.

1. Variables in utilities

Wrong (v3 habit) Right (v4) Why
duration-[--duration-fast] duration-(--duration-fast) brackets are literal: emits transition-duration: --duration-fast, invalid → 0s (lint/tw-v3-arbitrary-var). The owner's app had 131 of these.
ease-[--ease-out] ease-out (theme utility) or ease-(--ease-out) same
bg-[--brand] bg-brand or bg-(--brand) same
h-[var(--header-h)] h-(--header-h) shorter, same output
shadow-[var(--elevation-highlight)] shadow-(--elevation-highlight)

2. Classes that emit nothing in a void project

void's base.css removes Tailwind defaults so off-system styling can't slip in:

Removed Use instead
default palette: bg-zinc-900, text-gray-500, border-slate-200, from-indigo-500 semantic roles: bg-surface, text-fg-muted, border-line, bg-brand
shadow-sm/md/lg/xl/2xl shadow-1, shadow-2, shadow-3, shadow-(--elevation-highlight)
text-5xl … text-9xl text-display-sm, text-display, text-display-lg, text-display-xl
text-primary, bg-primary, bg-muted, text-muted-foreground (shadcn names) void roles, or import styles/void/shadcn.css if using shadcn components
anything you invent: rounded-card, ease-smooth, bg-backdrop define it in @theme first, or use an existing token

Kept from Tailwind: spacing scale, font-medium/weights, tracking-*, leading-*, max-w-xl etc., rounded-full, rounded-none, ease-in/ease-linear, numeric duration-150. Prefer tokens anyway.

Adding a token correctly:

css
/* globals.css, after the direction import */
@theme {
  --color-highlight: oklch(92% 0.12 95);   /* static value → bg-highlight, text-highlight */
}
@theme inline {
  --color-chart-1: var(--brand);           /* runtime var → must be `inline` so .dark subtrees work */
}

Never --x: var(--x) in @theme inline (self-reference cycle). Runtime names differ from theme names (--elevation-2--shadow-2, --brand--color-brand).

Defining a token can break old code: adding --color-primary suddenly styles every bg-primary pasted from a registry. After token changes, rebuild and look at untouched pages.

3. Config and variants

  • JS config is ignored. tailwind.config.ts does nothing without @config (lint/tw-js-config-ignored). Delete it; theme lives in CSS. Keyframes pasted into a JS config are dead animations.
  • Dark variant. Default dark: = prefers-color-scheme. void base declares @custom-variant dark (&:where(.dark, .dark *, [data-theme="dark"], [data-theme="dark"] *)); (lint/tw-dark-variant-os). With semantic tokens you rarely need dark: at all.
  • Hover is already wrapped in @media (hover: hover), so no sticky hover on touch. Pointer-precision effects: pointer-fine:.
  • Reduced motion: motion-safe: / motion-reduce:.
  • Starting style: starting:opacity-0 compiles to @starting-style.
  • Container queries: @container on parent, @md:grid-cols-2 on children; named @container/card + @lg/card:flex-row.
  • Supports: supports-[backdrop-filter]:backdrop-blur-md, supports-[animation-timeline:view()]:….
  • Arbitrary variants for descendants: [&_p+p]:mt-6, [&>svg]:size-4.

4. v3 → v4 renames (paste audit, lint/tw-v3-renamed)

v3 v4
shadow-sm / shadow shadow-xs / shadow-sm (void: shadow-1)
rounded-sm / rounded rounded-xs / rounded-sm
blur-sm / blur blur-xs / blur-sm
drop-shadow-sm drop-shadow-xs
outline-none outline-hidden (outline-none now sets outline-style: none)
ring (3px) ring-3 (bare ring = 1px)
bg-gradient-to-r bg-linear-to-r (also bg-radial, bg-conic)
flex-shrink-0 / flex-grow shrink-0 / grow
bg-opacity-50, text-opacity-* bg-black/50 style modifiers
decoration-slice box-decoration-slice
overflow-ellipsis text-ellipsis
default border color gray-200 currentColor: always write border-line*
space-y-* on inline children prefer flex flex-col gap-* (selector changed)
@tailwind base; … @import "tailwindcss";
theme() in CSS var(--color-…)

5. Cascade traps

  • Unlayered CSS beats utilities regardless of specificity. Vendor CSS (xterm.css, a pasted component stylesheet) must go in @layer components or be scoped.
  • No global * { border-color: … }. It overrides every border that relies on currentColor (dashed dividers vanished in the owner's app).
  • !important soup (lint/tw-important-soup) means a conflicting global rule; fix the source.
  • Direction overrides are unlayered :root rules, so they correctly beat @layer theme defaults. Project overrides go after the direction import.
  • Fonts from next/font are CSS variables on ; the direction's --font-sans: var(--font-geist-sans, "Geist"), … picks them up. If the variable name doesn't match, text silently falls back to system-ui. Compare layout.tsx variable names to the direction header.

6. Arbitrary values

Allowed: max-w-[16ch] (headline measure), one-off gradients built from tokens (bg-[radial-gradient(…var(--brand)…)]), [scrollbar-width:thin]. Not allowed: colors (text-[#777]), off-grid spacing (p-[13px]), radii (rounded-[7px]) (lint/tw-arbitrary-sprawl, design/spacing-off-grid). Underscores become spaces inside brackets: bg-[radial-gradient(60%_70%_at_20%_0%,…)].

7. Verify

bash
void lint                      # tw-unknown-class, tw-v3-*, hardcoded-colors, arbitrary sprawl
bun run build                  # next build must pass
bun packages/tokens/scripts/validate.ts   # (void repo only) contract utilities emit CSS in every direction