Available for day contractsFrom 21st September I have availability for day and half day contracts. Please contact for more information.

Contact →
mikepreston.org

Tailwind CSS v4

Tailwind CSS v4 — the CSS-first, zero-config rewrite: @import "tailwindcss", @theme/@utility/@variant, the Oxide engine, automatic content detection, container queries, and migrating from v3.

Tailwind CSS v4

The CSS-first, zero-config rewrite of Tailwind — configuration lives in your CSS, not a JavaScript file.

Overview

Tailwind CSS v4 (released January 2025) is a ground-up rewrite built on the new Oxide engine (Rust + a Lightning CSS pipeline). The headline change is CSS-first configuration: a single @import "tailwindcss"; replaces the three @tailwind directives, and your design tokens, custom utilities, and variants are now declared in CSS via @theme, @utility, and @custom-variant. There is no tailwind.config.js by default, and no content: [] array — v4 scans your project automatically.

This sheet covers what is new or different in v4. The utility vocabulary (spacing, colours, flexbox, grid, responsive prefixes, state variants) is unchanged in spirit from v3 — for that, see the Tailwind CSS (v3) sheet. Focus here is the new config model, directives, engine features, and the v3→v4 migration.

Oxide engine (Rust)Auto-detected sourcesHTML / JSX / Vue /Svelte@source / @sourcenot(explicit overrides)app.css@import"tailwindcss";@theme { ... }@utility /@custom-variantScan classesGenerate utilities+ theme as CSS varsCompiled CSS(cascade layers,@property,OKLCH, color-mix)Oxide engine (Rust)Auto-detected sourcesHTML / JSX / Vue /Svelte@source / @sourcenot(explicit overrides)app.css@import"tailwindcss";@theme { ... }@utility /@custom-variantScan classesGenerate utilities+ theme as CSS varsCompiled CSS(cascade layers,@property,OKLCH, color-mix)

Browser support — read this first. v4 targets modern browsers only: Safari 16.4+, Chrome 111+, Firefox 128+. It leans on @property, color-mix(), and native cascade layers (@layer), none of which degrade gracefully. If you must support older browsers, stay on v3 — there is no v4 build target that backfills these. This is the single biggest adoption gotcha.

Installation

There is no npx tailwindcss init step in v4 — the JS config file is optional (a compatibility shim), so most projects never create one. Pick an integration:

# 1. Vite plugin — the recommended path for app frameworks
npm install tailwindcss @tailwindcss/vite
// vite.config.js
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [tailwindcss()],
})
# 2. PostCSS plugin — for setups already wired through PostCSS
npm install tailwindcss @tailwindcss/postcss
// postcss.config.mjs
export default {
  plugins: { '@tailwindcss/postcss': {} },
}
# 3. CLI (npm) — framework-free builds
npm install tailwindcss @tailwindcss/cli
npx @tailwindcss/cli -i input.css -o output.css --watch

# 4. Standalone CLI — single binary, no Node toolchain at all
tailwindcss -i input.css -o output.css --watch

Your CSS entry point is then just:

/* app.css */
@import "tailwindcss";

That one line replaces v3's @tailwind base; @tailwind components; @tailwind utilities;.

CSS-First Configuration

@import "tailwindcss"

A single import pulls in Preflight (base reset), the theme, and the utility layer. The old @tailwind directives are gone — using them in v4 is an error.

@theme — tokens as real CSS variables

Design tokens are declared in a @theme block. Crucially, every token becomes a real CSS custom property on :root, so you can read them at runtime with var(--color-*) — no more reaching into JS config to share values with hand-written CSS or inline styles.

@import "tailwindcss";

@theme {
  --color-brand: oklch(0.62 0.19 260);
  --font-display: "Satoshi", sans-serif;
  --spacing-18: 4.5rem;            /* adds the `18` spacing step */
  --breakpoint-3xl: 120rem;        /* adds the `3xl:` breakpoint */
}

Compiles to (abridged, verified on v4.3.1):

:root, :host {
  --color-brand: oklch(0.62 0.19 260);
  --font-display: "Satoshi", sans-serif;
  /* ... */
}
.bg-brand     { background-color: var(--color-brand); }
.font-display { font-family: var(--font-display); }
.mt-18        { margin-top: var(--spacing-18); }

@media (width >= 120rem) {   /* the 3xl: breakpoint */
  .\33\ xl\:flex { display: flex; }
}

The namespace prefix decides what utilities a token generates: --color-* → colour utilities (bg-*, text-*, border-*), --font-* → font-*, --spacing-* → spacing utilities, --breakpoint-* → responsive variants, --text-* → text-{size}, --radius-* → rounded-*, and so on.

Use var() directly anywhere — that's the runtime payoff:

.custom-glow {
  box-shadow: 0 0 2rem var(--color-brand);
}

@theme inline

By default a token emits a --color-x property and utilities reference it via var(). With @theme inline, the resolved value is substituted directly into the utility and no extra custom property is added — useful when the value is itself a var() you control elsewhere (e.g. a token that should follow a theme switch without an indirection layer).

@theme inline {
  --color-accent: var(--brand, #ff0000);
}
/* verified output: value is inlined, no `--color-accent` on :root */
.text-accent { color: var(--brand, #ff0000); }

Automatic Content Detection

v4 finds your template files automatically — no content: [] array. It walks the project from the CSS file's location, respects .gitignore, and skips binary files and node_modules. For the common case you configure nothing.

When you need to override the heuristics, use @source:

@import "tailwindcss";

/* Add a source the auto-scanner would miss (e.g. outside the root,
   or a path .gitignore excludes) */
@source "../shared-ui/**/*.{html,vue}";

/* Exclude a directory from scanning */
@source not "./vendor/**/*";

/* Register classes from a dependency that ships its own markup */
@source "../node_modules/my-component-lib";

Verified: with @source "./src/**/*.html" a p-7 in src/page.html is emitted, and @source not "./vendor/**/*" keeps a p-99 in vendor/junk.html out of the build.

Note on dynamic strings. Auto-detection still only sees complete, literal class names in your source. Constructed strings like `bg-${c}-500` are invisible — the v3 rule holds. To force-include classes, use the @source inline(...) form, which replaces v3's safelist.

New Directives and APIs

@utility — custom utilities

Defines a utility that participates in variants (hover:, md:, etc.) and sits in the correct cascade layer. This is the v4 replacement for @layer utilities { ... } and addUtilities().

@utility tab-4 {
  tab-size: 4;
}

@utility btn {
  border-radius: 0.5rem;
  padding-inline: 1rem;
}
/* verified output */
.tab-4 { tab-size: 4; }
.btn   { border-radius: 0.5rem; padding-inline: 1rem; }

@custom-variant and @variant

@custom-variant registers a new variant; @variant applies an existing variant inside a rule. The most common use of @custom-variant is switching dark mode to a class/selector strategy (see below).

/* register a `theme-midnight:` variant */
@custom-variant theme-midnight (&:where([data-theme="midnight"] *));
/* apply a variant inside an @utility body */
@utility content-card {
  background: white;
  @variant dark {
    background: black;
  }
}

@plugin — load a JS plugin

@import "tailwindcss";
@plugin "@tailwindcss/typography";

Verified: prose utilities are emitted. First-party plugins (@tailwindcss/typography, @tailwindcss/forms) work unchanged; load them with @plugin instead of the v3 plugins: [] array.

@config — JS config compatibility shim

If you have an existing tailwind.config.js you'd rather not port yet, point at it explicitly:

@import "tailwindcss";
@config "./tailwind.config.js";
// tailwind.config.js (legacy, still honoured via @config)
module.exports = {
  theme: { extend: { colors: { legacy: "#123456" } } },
}

Verified: bg-legacy resolves to #123456. Note that content, corePlugins, and a few other v3-only keys are not read from a @config file — it's a theme/plugin bridge, not a full v3 emulator.

@reference and @apply in separate files

@apply still works, but in v4 a CSS file that uses it must know about your theme. Your main stylesheet does (it has the @import), but a component-scoped <style> block (Vue/Svelte) or a separate CSS module does not — applying a utility there without context fails or pulls in the whole framework. Use @reference to import the theme for resolution only, without re-emitting Preflight or the utility layer:

/* Button.vue <style> or a CSS module */
@reference "../app.css";

.my-btn {
  @apply p-4 bg-red-500;
}

Verified: output is just the .my-btn rule (padding + background) — no Preflight, no theme dump. (If you only need built-in tokens you can @reference "tailwindcss";.) For pure variable access, prefer plain var(--color-red-500) over @apply — it's cheaper and clearer.

Dark Mode

This is a breaking change from v3. In v4 the dark: variant defaults to prefers-color-scheme (the OS setting). There is no darkMode: 'class' config key any more — to get class/selector-based toggling you must register it yourself with @custom-variant:

@import "tailwindcss";

/* toggle on a `.dark` class anywhere up the tree */
@custom-variant dark (&:where(.dark, .dark *));
/* verified: dark:bg-gray-900 now keys off the .dark class */
.dark\:bg-gray-900 {
  &:where(.dark, .dark *) {
    background-color: var(--color-gray-900);
  }
}

Usage in markup is identical to v3:

<html class="dark">
  <body class="bg-white dark:bg-gray-900">
    <h1 class="text-gray-900 dark:text-white">Adapts to dark mode</h1>
  </body>
</html>
// toggle is unchanged
document.documentElement.classList.toggle('dark');

If you omit the @custom-variant line, dark: utilities still compile — but they fire from the OS preference and your .dark class does nothing. That silent mismatch is a frequent v4 support question.

New Engine and Features

Oxide performance

The Rust-based Oxide engine rebuilds incrementally in microseconds and does full builds several times faster than v3's PostCSS pipeline. Nothing to configure — it's the default.

Dynamic utility values (no config)

v4 generates spacing, grid, and many other utilities on demand from a formula, so values that v3 required you to add to theme.extend now just work:

<div class="mt-17 grid-cols-15 w-[calc(100%-2rem)]">…</div>
/* verified output */
.mt-17        { margin-top: calc(var(--spacing) * 17); }
.grid-cols-15 { grid-template-columns: repeat(15, minmax(0, 1fr)); }
.w-\[calc\(100\%-2rem\)\] { width: calc(100% - 2rem); }

Arbitrary values ([...]) work as in v3, but you'll reach for them far less.

Container queries (built in)

Container query support is now core — no @tailwindcss/container-queries plugin. Mark a containment context with @container, then size children with @sm:/@md: container variants (note the leading @, which distinguishes them from viewport breakpoints) and @max-*: for upper bounds.

<div class="@container">
  <div class="@sm:flex @md:grid @max-md:hidden">…</div>
</div>
/* verified output */
.\@container       { container-type: inline-size; }
.\@sm\:flex        { @container (width >= 24rem) { display: flex; } }
.\@md\:grid        { @container (width >= 28rem) { display: grid; } }
.\@max-md\:hidden  { @container (width < 28rem)  { display: none; } }

3D transforms

<div class="rotate-x-45 perspective-distant transform-3d">…</div>
/* verified output */
.rotate-x-45        { --tw-rotate-x: rotateX(45deg); transform: var(--tw-rotate-x,) …; }
.perspective-distant{ perspective: var(--perspective-distant); }  /* 1200px */
.transform-3d       { transform-style: preserve-3d; }

Also new: rotate-y-*, rotate-z-*, translate-z-*, scale-z-*, and backface-visible/backface-hidden.

New variants

<!-- not-* : negates a variant -->
<button class="not-hover:opacity-50">…</button>

<!-- starting: maps to @starting-style for enter animations -->
<dialog class="opacity-100 starting:open:opacity-0">…</dialog>

<!-- *: direct children, **: all descendants -->
<ul class="*:p-2 **:text-sm">…</ul>

<!-- in-*: like group-* but without marking the parent a `group` -->
<div class="in-[.sidebar]:pl-4">…</div>
/* verified output */
.not-hover\:opacity-50 { &:not(*:hover) { opacity: 50%; } }
.starting\:opacity-0   { @starting-style { opacity: 0%; } }
.\*\:p-2               { :is(& > *) { padding: calc(var(--spacing) * 2); } }

field-sizing

<textarea class="field-sizing-content">…</textarea>
.field-sizing-content { field-sizing: content; }   /* auto-grow to content */

OKLCH palette and color-mix() opacity

The default palette is now defined in OKLCH (wider gamut, more perceptually even):

--color-red-500: oklch(63.7% 0.237 25.331);   /* verified */

Opacity modifiers compile to color-mix() rather than baking an rgb(... / a) value:

<div class="bg-black/50">…</div>
/* verified output */
.bg-black\/50 {
  background-color: color-mix(in srgb, #000 50%, transparent);
}

This is also why bg-opacity-* / text-opacity-* were removed (see migration) — /<alpha> is the only form now.

Migrating from v3

Run the automated upgrade tool first; it rewrites CSS, migrates tailwind.config.js into @theme, and renames classes across templates:

# in a v3 project, on a clean git branch
npx @tailwindcss/upgrade

Then review these breaking changes by hand:

v3 v4 Notes
@tailwind base/components/utilities @import "tailwindcss"; old directives are an error
tailwind.config.js (default) @theme { … } in CSS JS config only via @config shim
content: [...] automatic detection / @source no array by default
darkMode: 'class' @custom-variant dark (…) default is now prefers-color-scheme
shadow-sm shadow-xs every shadow shifts down one step
shadow shadow-sm the bare shadow is now shadow-sm
outline-none outline-hidden outline-none now means outline-style: none
ring (3px) ring (1px) default ring width changed; use ring-3 for the old look
bg-opacity-50 bg-black/50 *-opacity-* utilities removed
default border/divide colour gray-200 currentColor set an explicit colour or restore via @theme
@layer utilities { … } @utility … custom utilities
plugins: [...] @plugin "…"; JS plugins

Verified renames (v4.3.1 output):

.shadow-xs     { --tw-shadow: 0 1px 2px 0 …; }   /* was shadow-sm in v3 */
.shadow-sm     { --tw-shadow: 0 1px 3px 0 …; }   /* was shadow in v3 */
.outline-hidden{ --tw-outline-style: none; outline-style: none; }
.ring          { --tw-ring-shadow: … 0 0 0 calc(1px + …); }  /* 1px, not 3px */
.border        { border-style: var(--tw-border-style); border-width: 1px; }

Border-colour gotcha. Because the default border colour moved from gray-200 to currentColor, an unstyled border that looked grey in v3 will now inherit text colour. Either add an explicit border-gray-200, or restore the old default in @theme.

Quick Reference

Key v3 → v4 changes

Area v3 v4
Entry @tailwind ×3 @import "tailwindcss";
Config tailwind.config.js @theme { … } in CSS
Content content: [] automatic + @source
Custom utilities @layer utilities @utility
Variants addVariant() @custom-variant / @variant
Plugins plugins: [] @plugin
Dark mode darkMode: 'class' @custom-variant dark (…)
Theme at runtime JS-only real var(--color-*)
Engine PostCSS Oxide (Rust)
Colour space sRGB hex OKLCH
Opacity baked alpha color-mix()

New directives

Directive Purpose
@import "tailwindcss"; load the framework
@theme { … } define design tokens (→ CSS vars)
@theme inline { … } inline token values, no extra var
@source "…" / @source not "…" add/exclude content sources
@utility name { … } custom utility
@custom-variant name (…) register a variant
@variant name { … } apply a variant in a rule
@plugin "…" load a JS plugin
@config "…" bridge a legacy tailwind.config.js
@reference "…" import theme for @apply without re-emitting CSS

New engine features at a glance

  • Dynamic values without config: mt-17, grid-cols-15, w-1/7
  • Container queries: @container, @sm:/@md:, @max-md:
  • 3D transforms: rotate-x-*, rotate-y-*, perspective-*, transform-3d
  • Variants: not-*, in-*, *:, **:, starting:
  • field-sizing-content for auto-growing inputs

Common Issues and Solutions

@tailwind directives don't work

Problem: @tailwind base; @tailwind components; @tailwind utilities; errors or produces nothing.

Solution: v4 removed them. Use a single line:

@import "tailwindcss";

dark: classes don't toggle

Problem: Toggling a .dark class on <html> has no effect; styles only change with the OS theme.

Solution: v4 defaults dark: to prefers-color-scheme. Register the class strategy explicitly:

@custom-variant dark (&:where(.dark, .dark *));

"Unsupported in this browser" / styles look broken in older browsers

Problem: Colours, rings, or layers render wrong in Safari < 16.4, Chrome < 111, or Firefox < 128.

Solution: v4 requires @property, color-mix(), and native @layer. There's no polyfill path — stay on v3 if you must support those browsers. Pin tailwindcss@3 and follow the v3 sheet.

JS config is ignored

Problem: Theme values in tailwind.config.js don't apply after upgrading.

Solution: v4 doesn't read a config file by default. Either migrate the theme into @theme { … }, or bridge the old file:

@config "./tailwind.config.js";

(Remember content/corePlugins keys are not honoured via @config.)

@apply fails in a Vue/Svelte <style> or CSS module

Problem: @apply throws "cannot apply unknown utility" in a component-scoped block.

Solution: That file doesn't see your theme. Add a reference at the top:

@reference "../app.css";   /* or @reference "tailwindcss"; for built-ins only */

A class I build dynamically isn't generated

Problem: `bg-${colour}-500` produces no CSS.

Solution: Auto-detection only sees literal class names — same as v3. Use complete strings, or force-include with @source inline("bg-red-500 bg-blue-500").

Borders suddenly the wrong colour after upgrade

Problem: Elements with a bare border now show text colour instead of light grey.

Solution: The default border colour is currentColor in v4. Add an explicit border-gray-200, or restore the old default in @theme.

Related Topics

To complement this Tailwind CSS v4 cheatsheet, consider these related topics:

  1. Tailwind CSS (v3) — the shared utility vocabulary (spacing, colours, flexbox/grid, responsive and state variants) that v4 inherits; the fallback for older-browser projects.
  2. SCSS — the preprocessor workflow Tailwind's CSS-first model partly displaces; still useful for nesting and mixins alongside Tailwind.
  3. CSS Grid — the layout engine behind grid-cols-*, grid-rows-*, and arbitrary track definitions.
  4. CSS Flexbox — one-dimensional layout via flex, justify-*, and items-*.
  5. HTML5 — the markup Tailwind's utility classes decorate.
  6. Web Performance Optimisation — minified output, critical CSS, and keeping the generated bundle lean.