tailwind customization

[check: …]
📖 Pair this live Tailwind playground with the companion guide (.md) — this page is the rendered ground truth.

Three escape hatches + the production question

The page CHROME (header, panels) is our own dark CSS. Everything inside the bordered stage boxes below is produced by Tailwind v4 directives compiled in the <style type="text/tailwindcss"> block in <head>. When a built-in utility doesn't exist, you reach for one of three hatches; when the CDN gets slow, you graduate to a real build.
<!-- v4 Play CDN — verified, jsDelivr @tailwindcss/browser@4 (4.3.1) --> <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> <!-- then, custom Tailwind CSS goes in a typed style block --> <style type="text/tailwindcss"> @utility tab-4 { tab-size: 4; } @custom-variant midnight (&:where([data-theme="midnight"] *)); .card { @apply rounded-2xl bg-slate-800 border border-slate-700 p-6 shadow-xl; } </style>

Old v3 added utilities/variants via tailwind.config.js plugins. v4 moved all of that INTO CSS via these directives.

(1) @utility — add a utility that doesn't exist

@utility registers a custom single-purpose class that ships with every variant (hover:, lg:, …) just like a built-in. It is the v4 replacement for the v3 "add a plugin" pattern.

tab-4 (custom utility) applied to a <pre>

function f() {
	return [	// this line starts with TWO real tab chars
		"tab-size:4 makes them 4 spaces wide",
		"each	red	one here is a tab too",
	];
}

A complex (nested) custom utility — no-scrollbar hides the scrollbar of the box below (scroll it):

no-scrollbar{ scrollbar-width:none; &::-webkit-scrollbar{ display:none } }
— line —
— line —
— line —
— line —
— line —
— line —
— end —
@utility tab-4 { tab-size: 4; } /* simple */ @utility no-scrollbar { /* complex — nesting allowed */ scrollbar-width: none; &::-webkit-scrollbar { display: none; } }

@utility: measuring…

(2) @apply — bundle many utilities into one class

@apply inlines existing utilities into a custom CSS class. Reach for it sparingly (it hides which utilities compose the class, and can raise specificity). Best use: third-party overrides, or a handful of repeated clusters.
A .card built with @apply
This card's radius, fill, border, padding and shadow are each a utility inlined into .card by @apply — so the markup stays clean while the class still yields to a plain utility (e.g. an extra rounded-none would override it).
bg-brand (theme color) plain
.card { @apply max-w-sm rounded-2xl bg-slate-800 border border-slate-700 p-6 shadow-xl; } .card-title { @apply text-lg font-bold text-slate-100; } .card-body { @apply text-sm text-slate-300 leading-relaxed mt-2; }

@apply: measuring…

(3) @custom-variant + variant stacking

@custom-variant names your own condition (a data attribute, a media query, a selector). Variants also stack — leftmost is the outermost wrapper.

[data-theme="midnight"] scope

this span carries the custom variant class midnight:underline

The class only takes effect BECAUSE this box is [data-theme="midnight"]. Move it out and the underline vanishes.

Variant stacking — hover:md:bg-brand

Three conditions composed: hover (outer) wraps md (≥768px) wraps the bg-brand utility. Resize the window ≥768px and hover:

@custom-variant midnight (&:where([data-theme="midnight"] *)); /* usage: */ <span class="midnight:underline">…</span> /* stacked: leftmost variant is the OUTERMOST selector */ <button class="hover:md:bg-brand">…</button> /* == @media(md){ &:hover{ bg-brand } } */

@custom-variant: measuring…

When to escape the Play CDN — runtime-JIT vs a real build

The Play CDN ships the entire compiler to the browser and recompiles your classes at runtime (JIT-in-the-browser). Great for a prototype, bad for production: larger payload, a flash of unstyled content, and no tree-shaking. Graduation = a build step that emits one small, static, zero-runtime CSS file.
pathwhenhow styles are madecost
Play CDN
@tailwindcss/browser@4
prototyping, demos, this bundle compiler runs IN the browser; compiles on load + on DOM change ships compiler JS; runtime JIT; FOUC; not tree-shaken
Real build
CLI / @tailwindcss/vite / postcss
production compiler runs at BUILD time; scans source, emits static CSS tiny CSS, zero runtime, purged/tree-shaken, cacheable
# production: the v4 build pipeline (NOT the CDN) npm i tailwindcss @tailwindcss/vite # or @tailwindcss/postcss, or the `tailwind` CLI # app.css @import "tailwindcss"; # same @utility / @apply / @custom-variant work here # build emits a single, static, tree-shaken style.css — zero runtime.

Same directives (@utility, @apply, @custom-variant) work identically under a real build — that is the whole point: prototype on the CDN, ship the build.