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:
/* 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.tsdoes 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 needdark: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-0compiles to@starting-style. - Container queries:
@containeron parent,@md:grid-cols-2on 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 componentsor be scoped. - No global
* { border-color: … }. It overrides every border that relies oncurrentColor(dashed dividers vanished in the owner's app). !importantsoup (lint/tw-important-soup) means a conflicting global rule; fix the source.- Direction overrides are unlayered
:rootrules, so they correctly beat@layer themedefaults. Project overrides go after the direction import. - Fonts from
next/fontare 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. Comparelayout.tsxvariable 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
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