Tailwind in Practice
Knowing the utilities is the easy half. This page is about the decisions: how to structure a theme, when to reach for an escape hatch, how to keep class strings manageable, and which Tailwind behaviours quietly break things.
Everything here targets Tailwind v4; v3 differences are marked.
Customising the theme in v4
Section titled “Customising the theme in v4”@theme defines design tokens as CSS custom properties. Tailwind reads them and generates matching utilities.
@import "tailwindcss";
@theme { /* colours -> bg-brand-500, text-brand-500, border-brand-500, ring-brand-500, … */ --color-brand-50: oklch(0.97 0.02 259); --color-brand-500: oklch(0.62 0.19 259); --color-brand-900: oklch(0.35 0.13 259);
/* fonts -> font-display */ --font-display: "Cabinet Grotesk", ui-sans-serif, system-ui, sans-serif;
/* type scale -> text-hero */ --text-hero: clamp(2.5rem, 1.5rem + 4vw, 5rem); --text-hero--line-height: 1.05; --text-hero--letter-spacing: -0.02em;
/* breakpoints -> 3xl:… */ --breakpoint-3xl: 120rem;
/* radii -> rounded-card */ --radius-card: 0.875rem;
/* easing -> ease-snap */ --ease-snap: cubic-bezier(0.2, 0, 0, 1);}Each namespace controls a family of utilities:
| Namespace | Generates |
|---|---|
--color-* |
bg-*, text-*, border-*, fill-*, stroke-*, ring-*, outline-*, divide-*, shadow-* colours |
--font-* |
font-* (families) |
--text-* |
text-* (sizes), with --text-x--line-height and --text-x--letter-spacing sub-keys |
--font-weight-* |
font-* (weights) |
--spacing |
the whole spacing scale (p-*, m-*, gap-*, w-*, …) |
--breakpoint-* |
responsive variants |
--container-* |
max-w-* and @container variants |
--radius-* |
rounded-* |
--shadow-*, --inset-shadow-*, --drop-shadow-* |
shadow utilities |
--blur-*, --perspective-*, --aspect-* |
the matching utilities |
--ease-*, --animate-* |
ease-*, animate-* |
--tracking-*, --leading-* |
tracking-*, leading-* |
Theme variables are also emitted as real CSS custom properties on :root, so you can use them in hand-written CSS or read them from JavaScript:
.legacy-widget { background: var(--color-brand-500); }Replacing rather than extending
Section titled “Replacing rather than extending”@theme adds to the defaults. To remove a namespace entirely, set it to initial first:
@theme { --color-*: initial; /* drop the whole default palette */ --color-white: #fff; --color-ink: oklch(0.2 0.01 260); --color-brand: oklch(0.62 0.19 259);}--*: initial; clears every default token. Useful when a design system must be exhaustive and you want unknown utilities to fail loudly.
@theme inline
Section titled “@theme inline”If a token’s value references another variable that changes at runtime, use @theme inline so Tailwind substitutes the value into the utility rather than pointing at a variable indirection:
:root { --brand: oklch(0.62 0.19 259); }:root[data-theme="dark"] { --brand: oklch(0.72 0.15 259); }
@theme inline { --color-brand: var(--brand);}Now bg-brand resolves through --brand and follows the theme switch.
Semantic tokens beat dark: everywhere
Section titled “Semantic tokens beat dark: everywhere”Writing dark: on every element is unmaintainable. Define semantic colours once and let them flip:
@import "tailwindcss";@custom-variant dark (&:where(.dark, .dark *));
:root { --surface: oklch(1 0 0); --surface-muted: oklch(0.97 0.005 260); --content: oklch(0.2 0.01 260); --content-muted: oklch(0.55 0.02 260); --line: oklch(0.92 0.005 260);}
.dark { --surface: oklch(0.18 0.01 260); --surface-muted: oklch(0.23 0.012 260); --content: oklch(0.96 0.005 260); --content-muted: oklch(0.72 0.015 260); --line: oklch(0.3 0.012 260);}
@theme inline { --color-surface: var(--surface); --color-surface-muted: var(--surface-muted); --color-content: var(--content); --color-content-muted: var(--content-muted); --color-line: var(--line);}The markup then has no dark: variants at all:
<div class="bg-surface text-content border border-line rounded-card p-6"> <p class="text-content-muted">Works in both themes.</p></div>The v3 equivalent
Section titled “The v3 equivalent”/** @type {import('tailwindcss').Config} */module.exports = { content: ['./src/**/*.{html,js,jsx,ts,tsx,vue,astro}'], darkMode: 'class', theme: { extend: { colors: { brand: { 50: '#eef4ff', 500: '#3b6ef5', 900: '#1b2f6b' }, }, fontFamily: { display: ['Cabinet Grotesk', 'sans-serif'] }, borderRadius: { card: '0.875rem' }, screens: { '3xl': '120rem' }, }, }, plugins: [require('@tailwindcss/typography')],};Note the structural differences: theme replaces, theme.extend adds (v4’s @theme always extends unless you use initial); v3 tokens are not CSS variables by default; and plugins are require()d in JS rather than declared with v4’s @plugin "@tailwindcss/typography";.
Arbitrary values
Section titled “Arbitrary values”When the design system genuinely does not have the value, use square brackets rather than dropping to a stylesheet.
<div class="top-[117px] w-[37ch] bg-[#1da1f2] text-[13px]">…</div>Rules:
- Spaces become underscores:
grid-cols-[1fr_500px_2fr],shadow-[0_4px_12px_rgb(0_0_0/0.1)]. - To emit a literal underscore, escape it:
content-['hello\_world']. - Arbitrary properties use the full
[property:value]form:[mask-type:luminance],[scrollbar-gutter:stable]. - Custom properties have a shorthand in v4 — parentheses instead of
[var(--x)]:
<div class="bg-(--brand) w-(--sidebar-width)">…</div>- Ambiguous cases take a type hint:
text-(length:--size)sets font-size,text-(color:--tint)sets colour. Same forbg-[url(/img.png)]vsbg-[#fff], which Tailwind can infer.
Arbitrary variants use the same brackets with &:
<div class="[&>li]:mb-2 [&:has(:checked)]:ring-2 supports-[backdrop-filter]:backdrop-blur">…</div>Custom utilities
Section titled “Custom utilities”Do not define a utility with a plain CSS class — it would land outside Tailwind’s layers and lose the ordering guarantees. Use @utility:
@utility scrollbar-none { scrollbar-width: none; &::-webkit-scrollbar { display: none; }}
@utility text-balance-pretty { text-wrap: pretty;}These land in the utilities layer, work with every variant (md:scrollbar-none, hover:…), and are only emitted if used.
Custom variants use @custom-variant:
@custom-variant hocus (&:hover, &:focus-visible);@custom-variant aria-current (&[aria-current="page"]);<a class="text-slate-600 hocus:text-brand aria-current:font-semibold">Docs</a>@apply — and when not to
Section titled “@apply — and when not to”@apply inlines the declarations of utilities into a CSS rule.
.btn { @apply inline-flex items-center rounded-md bg-brand px-4 py-2 font-medium text-white;}It works, and it is almost always the wrong tool.
Why it is a trap:
- You are back to naming things and maintaining a stylesheet — the exact costs utilities removed.
- Deleting the component no longer deletes its CSS.
- The output is often larger than the utilities, because
@applycannot be deduplicated across elements. - Reading the markup no longer tells you what the element looks like.
- Overriding gets awkward:
class="btn px-8"may or may not win depending on generated order.
The right fix is component extraction in whatever templating system you already have:
export function Button({ variant = 'primary', className = '', ...props }) { const base = 'inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium ' + 'transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 ' + 'disabled:pointer-events-none disabled:opacity-50'; const variants = { primary: 'bg-brand text-white hover:bg-brand-600', ghost: 'bg-transparent text-content hover:bg-surface-muted', }; return <button className={`${base} ${variants[variant]} ${className}`} {...props} />;}Legitimate uses of @apply:
- Styling markup you do not control — output from a Markdown renderer, a third-party widget, CMS content.
- A tiny number of genuinely global primitives, when your stack has no component layer at all.
@layer components { .prose-table table { @apply w-full border-collapse text-sm; } .prose-table th { @apply border-b border-line px-3 py-2 text-left font-semibold; }}Conditional classes in templates
Section titled “Conditional classes in templates”Concatenating strings gets ugly fast. Two small libraries solve it.
clsx (or classnames) for conditionals:
import clsx from 'clsx';
<button className={clsx( 'rounded-md px-4 py-2 font-medium', isActive && 'bg-brand text-white', !isActive && 'bg-surface-muted text-content', disabled && 'opacity-50 pointer-events-none', )}/>tailwind-merge resolves conflicting utilities correctly:
import { twMerge } from 'tailwind-merge';
twMerge('px-4 py-2 px-8'); // => 'py-2 px-8'twMerge('bg-red-500', 'bg-blue-500'); // => 'bg-blue-500'This matters because Tailwind’s own conflict resolution is by generated CSS order, not by the order of classes in the attribute — class="p-8 p-2" and class="p-2 p-8" render identically. twMerge strips the loser so a className prop can reliably override component defaults.
The usual combination:
import clsx from 'clsx';import { twMerge } from 'tailwind-merge';
export function cn(...inputs) { return twMerge(clsx(inputs));}For multi-variant components, class-variance-authority (cva) formalises the pattern:
import { cva } from 'class-variance-authority';
export const button = cva('inline-flex items-center rounded-md font-medium transition-colors', { variants: { intent: { primary: 'bg-brand text-white hover:bg-brand-600', danger: 'bg-red-600 text-white hover:bg-red-700', }, size: { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-sm', lg: 'px-6 py-3 text-base' }, }, defaultVariants: { intent: 'primary', size: 'md' },});Common patterns
Section titled “Common patterns”<article class="group overflow-hidden rounded-card border border-line bg-surface shadow-sm transition hover:shadow-md"> <img src="/cover.jpg" alt="" class="aspect-video w-full object-cover transition-transform duration-300 group-hover:scale-105" /> <div class="space-y-2 p-5"> <h3 class="text-lg font-semibold text-content">Card title</h3> <p class="line-clamp-3 text-sm text-content-muted">Supporting copy that gets clipped after three lines.</p> <a href="#" class="inline-flex items-center gap-1 text-sm font-medium text-brand hover:underline">Read more</a> </div></article>Button
Section titled “Button”<button class="inline-flex items-center justify-center gap-2 rounded-md bg-brand px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors duration-150 hover:bg-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand active:bg-brand-900 disabled:pointer-events-none disabled:opacity-50"> Save changes</button>Responsive card grid
Section titled “Responsive card grid”<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">…</div>Or with no breakpoints at all:
<div class="grid gap-6 grid-cols-[repeat(auto-fit,minmax(min(16rem,100%),1fr))]">…</div>App shell
Section titled “App shell”<div class="grid min-h-dvh grid-rows-[auto_1fr_auto]"> <header class="sticky top-0 z-20 border-b border-line bg-surface/80 backdrop-blur">…</header> <div class="mx-auto grid w-full max-w-7xl gap-8 px-4 py-8 lg:grid-cols-[16rem_minmax(0,1fr)]"> <aside class="hidden lg:block">…</aside> <main class="min-w-0">…</main> </div> <footer class="border-t border-line py-8">…</footer></div>minmax(0,1fr) and min-w-0 are there for the same reason as in plain Grid — without them a wide code block or long URL blows out the layout.
Responsive nav with a mobile menu
Section titled “Responsive nav with a mobile menu”<nav class="border-b border-line"> <div class="mx-auto flex max-w-7xl items-center justify-between px-4 py-3"> <a href="/" class="font-display text-lg font-bold">Acme</a>
<!-- desktop links --> <ul class="hidden gap-6 md:flex"> <li><a href="/docs" class="text-sm text-content-muted hover:text-content">Docs</a></li> <li><a href="/pricing" class="text-sm text-content-muted hover:text-content">Pricing</a></li> </ul>
<!-- mobile toggle --> <button type="button" aria-expanded="false" aria-controls="mobile-menu" class="md:hidden rounded p-2 hover:bg-surface-muted"> <span class="sr-only">Open menu</span> <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M4 6h16M4 12h16M4 18h16" /> </svg> </button> </div>
<ul id="mobile-menu" class="hidden border-t border-line px-4 py-2 md:hidden"> <li><a href="/docs" class="block py-2">Docs</a></li> <li><a href="/pricing" class="block py-2">Pricing</a></li> </ul></nav>hidden md:flex and md:hidden are the whole mechanism. sr-only hides text visually while keeping it available to screen readers.
Form field
Section titled “Form field”<div class="space-y-1.5"> <label for="email" class="block text-sm font-medium text-content">Email</label> <input id="email" type="email" required class="peer block w-full rounded-md border border-line bg-surface px-3 py-2 text-sm placeholder:text-content-muted focus:border-brand focus:outline-2 focus:outline-offset-0 focus:outline-brand disabled:opacity-50 invalid:border-red-500" /> <p class="hidden text-sm text-red-600 peer-[&:not(:placeholder-shown):invalid]:block"> Enter a valid email address. </p></div>The arbitrary peer variant is doing the work: & is the peer element, and the message only
appears once the field has been typed into and is invalid. Plain peer-invalid:block would
flag an empty required field before the user has touched it.
Gotchas
Section titled “Gotchas”Class names must appear as complete strings
Section titled “Class names must appear as complete strings”Tailwind scans your source as plain text. It does not evaluate your code. A class only exists in the output if the full class name appears literally somewhere.
// BROKEN — "text-red-600" never appears in the sourceconst color = 'red';<p className={`text-${color}-600`}>…</p>
// WORKS — full class names are presentconst colors = { red: 'text-red-600', green: 'text-green-600' };<p className={colors[color]}>…</p>The same applies to any string built at runtime: template literals, join(), values from a CMS, class names computed in a database. Map to complete class strings instead.
When a class genuinely only exists at runtime, force it into the output. In v4.1+:
@source inline("bg-red-500 bg-green-500 bg-blue-500");In v3 the equivalent was a safelist array in tailwind.config.js.
Conflicting utilities resolve by generated order
Section titled “Conflicting utilities resolve by generated order”<!-- Both render the same: order in the attribute is irrelevant --><div class="p-2 p-8">…</div><div class="p-8 p-2">…</div>Tailwind emits utilities in a fixed order and the later rule wins. Use tailwind-merge when a component accepts overriding classes, and never rely on attribute order.
Specificity and @layer
Section titled “Specificity and @layer”v4 uses native CSS cascade layers:
@layer theme, base, components, utilities;Utilities sit in the last layer, so a one-class utility beats a much more specific rule in base or components. But unlayered CSS beats all layered CSS — a plain .card { padding: 0 } in a file imported after Tailwind will defeat p-6. Put your own overrides inside a layer:
@import "tailwindcss";
@layer components { .card { padding: 0; } /* now utilities can still override it */}For a genuine one-off override, the important modifier in v4 is a suffix:
<div class="bg-red-500!">…</div> <!-- v4 --><div class="!bg-red-500">…</div> <!-- v3 -->space-* vs gap-*
Section titled “space-* vs gap-*”space-y-4 uses a sibling selector to add margin to all but the first child. It breaks with wrapping, with reordered children, and inside grids. Use gap-4 on a flex or grid container instead — it is what space-* was approximating before gap had support.
Preflight surprises
Section titled “Preflight surprises”Preflight unstyles headings, lists, and <button>. Content coming from a Markdown renderer will look like undifferentiated text. That is what @tailwindcss/typography is for:
@plugin "@tailwindcss/typography";<article class="prose prose-slate dark:prose-invert max-w-none"> {{ markdownHtml }}</article>Content detection and ignored directories
Section titled “Content detection and ignored directories”v4 skips anything in .gitignore. If your templates live in a gitignored build directory, or come from a dependency, they will not be scanned. Add them explicitly:
@source "../node_modules/@my-org/ui-kit/dist";Long class strings
Section titled “Long class strings”They are a readability problem, not a technical one. Mitigations, in order of preference: extract a component; group related utilities in a consistent order (layout → box → typography → colour → state); use the official Prettier plugin to sort them automatically.
npm install -D prettier prettier-plugin-tailwindcss{ "plugins": ["prettier-plugin-tailwindcss"] }Key points
Section titled “Key points”- Customise with
@themein v4; use@theme inlinewhen a token wraps a variable that changes at runtime. - Semantic colour tokens that flip on
.darkbeat sprinklingdark:across the markup. - Arbitrary values are an escape hatch; more than a handful means your theme is missing tokens.
- Prefer component extraction over
@apply;@applyis for markup you do not control, and needs@referencein separately compiled CSS. - Class names must appear literally in the source — build lookup maps, never
text-${color}-500. - Conflicts resolve by generated order, not attribute order; use
tailwind-mergefor overridable components. - Unlayered CSS beats every Tailwind layer; put custom rules inside
@layer components. - Use
gap-*, notspace-*.