Eight weeks shipping a real app on Tailwind v4 — the silent
footgun, @theme inline vs @theme, color-mix in oklab, @source pragmas, and the font dance.
I migrated Linkette from Tailwind v3 to v4 over a weekend, three weeks into the build. The migration guide is good. The release notes are good. There is still a small pile of things that bit me, and I haven't seen them collected in one place.
Here is that pile.
1. The <alpha-value> v3 placeholder is silently dropped
In v3, the canonical pattern for theming with CSS variables was:
:root { --accent: 194 86 46; }
// tailwind.config.js (v3)
theme: {
colors: {
accent: "rgb(var(--accent) / <alpha-value>)",
},
},
That <alpha-value> placeholder was Tailwind's hook into opacity utilities: bg-accent/30 would become rgb(var(--accent) / 0.3).
In v4, inside @theme inline, that placeholder is not processed. It lands literally in the CSS variable. bg-accent/30 then evaluates to something like rgb(194 86 46 / <alpha-value>), which is invalid CSS — browsers compute it to transparent. Your buttons disappear. You ship a release and somebody screenshots a "ghost" UI on Bluesky.
The fix is to drop the placeholder entirely. v4's opacity utilities are generated via color-mix() against a valid color value, so resolving to a solid rgb(...) works perfectly:
/* apps/web/app/globals.css */
@theme inline {
--color-bg: rgb(var(--bg));
--color-bg-elevated: rgb(var(--bg-elevated));
--color-bg-muted: rgb(var(--bg-muted));
--color-text: rgb(var(--text));
--color-text-muted: rgb(var(--text-muted));
--color-text-subtle: rgb(var(--text-subtle));
--color-border: rgb(var(--border));
--color-border-strong: rgb(var(--border-strong));
--color-accent: rgb(var(--accent));
--color-accent-hover: rgb(var(--accent-hover));
--color-accent-soft: rgb(var(--accent-soft));
--color-secondary: rgb(var(--secondary));
--color-success: rgb(var(--success));
--color-warning: rgb(var(--warning));
--color-danger: rgb(var(--danger));
/* ... */
}
The token CSS variable holds three space-separated RGB integers:
/* packages/tokens/src/tokens.css */
:root, [data-theme="light"] {
--accent: 194 86 46; /* #C2562E */
--accent-hover: 166 72 31;
--accent-soft: 244 220 203;
/* ... */
}
The mapping wraps each one in rgb(...) to produce a valid color, and bg-accent/30 then works because v4 picks up --color-accent as a real color and generates color-mix(in oklab, var(--color-accent) 30%, transparent) for you.
I shipped two weeks of pages with this bug. It only manifested on hover states and soft tints, both visually quiet enough that I didn't catch it. Don't repeat my mistake.
2. @theme vs @theme inline
These are not synonyms. @theme evaluates expressions at build time and bakes them into the final CSS as concrete values. @theme inline re-emits the expressions as-is and lets them evaluate at runtime, in the cascade, against the current :root.
The difference matters the second you switch themes via [data-theme="dark"]. With @theme, --color-accent is baked to its light-mode value. Toggling data-theme changes nothing because the bake already happened.
With @theme inline, --color-accent: rgb(var(--accent)) is preserved literally — and because --accent is redefined by the [data-theme="dark"] selector, the live cascade re-resolves the accent color.
Rule of thumb: if your design tokens are static, use @theme. If they swap at runtime (theme toggle, multi-tenant brand colors), use @theme inline. Linkette is multi-tenant by theme palette, so it's inline everywhere.
3. color-mix() in oklab vs sRGB matters for accent tints
v4 generates opacity utilities like bg-accent/30 as color-mix(in oklab, var(--color-accent) 30%, transparent). The default color space is oklab. This is almost always what you want — perceptually uniform mixing keeps tints from going muddy.
But if you're hand-rolling tints in custom utilities, sRGB is the default for color-mix() if you forget the space, and the difference is visible. A terracotta #C2562E at 30% in srgb against cream looks dull beige; in oklab it stays warmly terracotta-tinted.
/* Bad — sRGB defaults give muddy results on warm accents */
background: color-mix(var(--accent) 30%, white);
/* Good — match what Tailwind generates */
background: color-mix(in oklab, var(--accent) 30%, white);
Tailwind handles this for you in generated utilities. Watch out in raw CSS.
4. Migrating from postcss-tailwindcss to @tailwindcss/postcss
In v3 the PostCSS plugin lived inside the main tailwindcss package. In v4 the PostCSS integration is a separate package: @tailwindcss/postcss. You also drop the tailwind.config.js import.
// apps/web/postcss.config.mjs
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
That's it. No config object, no tailwind.config.js. All theming and content-source declaration happens in CSS via @theme and @source.
The migration friction is in your editor: most Tailwind IntelliSense plugins still expected tailwind.config.js for autocomplete data. The official VS Code extension caught up; the JetBrains one took longer.
5. Custom variants via @custom-variant for theme attributes
v4 doesn't expose a default dark: variant for arbitrary attribute selectors. If you're toggling themes with next-themes's default data-theme="dark" attribute (rather than the class="dark" strategy), you have to declare the variant yourself:
/* apps/web/app/globals.css */
/* Dark-mode variant: Tailwind v4 needs to know `[data-theme="dark"]` toggles dark utilities. */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
The :where() wrapper keeps specificity at 0, which is critical — without it, dark:bg-accent will out-rank bg-accent even when no dark theme is active, breaking your light defaults. v3 generated this for you. v4 makes you say it.
6. Why we kept @source pragmas in a monorepo
In v4, Tailwind auto-detects which files to scan by walking up from your CSS entry. That works for single-app repos. It breaks for monorepos where some utility classes live in a sibling workspace package.
Linkette is a pnpm monorepo with shared UI components in packages/ui. Without explicit @source pragmas, classes used only inside packages/ui were getting tree-shaken because Tailwind's auto-walk didn't find them.
/* apps/web/app/globals.css */
@import "tailwindcss";
@import "@linkette/tokens/tokens.css";
/* Tell Tailwind v4 where to look for utility class usage in the monorepo. */
@source "../app/**/*.{ts,tsx}";
@source "../components/**/*.{ts,tsx}";
@source "../../../packages/ui/src/**/*.{ts,tsx}";
The path is resolved relative to the CSS file's location, not the project root. That tripped me twice before I locked it in.
7. The font loading dance
Tailwind v4 doesn't ship font helpers. Combine that with Next.js's next/font and a variable font with optical sizes and you get a small choreography:
/* packages/tokens/src/tokens.css (excerpt) */
/* Variable font axes via @font-face are declared once at the token layer. */
/* In app/globals.css the typography family vars are mapped: */
@theme inline {
--font-display: "Fraunces", Georgia, serif;
--font-sans: "Geist", system-ui, sans-serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, monospace;
}
Then in base typography:
@layer base {
body {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
font-feature-settings: "ss01", "cv11";
}
}
font-feature-settings: "ss01" activates the alternate stylistic set on Geist that gives you the friendlier double-storey g. For Fraunces, we use it in components for ss01 (alternate g) and ss02 (curlier r):
// apps/web/components/page-preview.tsx (excerpt)
<span style={{
color: m.accent,
fontFamily: "var(--font-display)",
fontFeatureSettings: "'ss01'",
}}>
✦
</span>
The reason this is in style= and not a Tailwind class: Tailwind v4 doesn't ship a font-feature-settings utility, and the arbitrary value syntax [font-feature-settings:'ss01'] quotes badly in JSX. Inline style is the least-bad option until v4 ships a font-features utility (it's on the roadmap).
The variable font's optical-size axis (opsz) is handled by the browser automatically via font-optical-sizing: auto;, which is the default since Chrome 100. You don't have to think about it — but you should set it explicitly in your base typography because some Safari versions still need the nudge.
8. Preflight no longer sets cursor: pointer on buttons
This one's mentioned in the v4 release notes but I want to amplify it because it's a silent UX regression. Buttons no longer get cursor: pointer by default. Your <button> elements work but feel dead on hover.
Restore it globally in your base layer, with explicit opt-outs for disabled/loading states:
@layer base {
/* Tailwind v4 Preflight no longer sets `cursor: pointer` on buttons.
Restore the expected affordance globally — disabled / drag-handle
/ loading-state buttons opt out explicitly with their own cursor
classes. */
button:not(:disabled),
[role="button"]:not(:disabled),
[role="switch"]:not(:disabled),
summary {
cursor: pointer;
}
}
9. Custom utilities via @utility
v4 replaces the v3 @layer utilities { .foo { ... } } pattern with a dedicated @utility at-rule. The functional difference is that @utility participates in the variant system — you get hover:, dark:, md: for free on your custom utility. The v3 layer pattern didn't.
/* apps/web/app/globals.css */
@utility scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
Nesting is allowed (no nesting plugin required). hover:scrollbar-hide now works.
Things I tried that didn't work
Migrating tokens into @theme instead of leaving them in a tokens package
I tried collapsing the :root variables and the @theme mappings into one block. It compiled, but it killed live theme swapping because everything baked at build time. Reverted to the two-layer setup: tokens declared as plain CSS variables in packages/tokens/src/tokens.css, mapped to Tailwind utility names in @theme inline in app/globals.css. The split also keeps the design tokens consumable by non-Tailwind contexts (the marketing site, raw SVGs).
Using @import "tailwindcss/preflight" à la carte
I wanted to skip preflight on a couple of legacy embed pages. v4's modular import structure suggests you can pick and choose. In practice the resulting cascade was just wrong — utilities depend on preflight resets in subtle ways and you end up firefighting individual selectors. Just use preflight everywhere or accept your custom CSS will fight it.
Subpath imports for the tokens package
I tried @import "@linkette/tokens/themes/atelier-nocturne.css" to load specific theme variants on demand. PostCSS resolution through pnpm's symlinked workspace dependencies was fragile (worked locally, broke in Docker). Solution: one tokens.css that declares all themes via [data-theme="..."] selectors, and next-themes swaps the attribute. CSS is cheap; HTTP round-trips aren't.