Skip to content

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.

@theme defines design tokens as CSS custom properties. Tailwind reads them and generates matching utilities.

src/app.css
@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); }

@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.

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.

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>
tailwind.config.js — v3 only
/** @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";.

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 for bg-[url(/img.png)] vs bg-[#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>

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 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 @apply cannot 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:

Button.jsx
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; }
}

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:

lib/cn.js
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' },
});
<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 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>
<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>
<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.

<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.

<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.

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 source
const color = 'red';
<p className={`text-${color}-600`}></p>
// WORKS — full class names are present
const 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.

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-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 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>

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";

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.

Terminal window
npm install -D prettier prettier-plugin-tailwindcss
.prettierrc
{ "plugins": ["prettier-plugin-tailwindcss"] }
  • Customise with @theme in v4; use @theme inline when a token wraps a variable that changes at runtime.
  • Semantic colour tokens that flip on .dark beat sprinkling dark: across the markup.
  • Arbitrary values are an escape hatch; more than a handful means your theme is missing tokens.
  • Prefer component extraction over @apply; @apply is for markup you do not control, and needs @reference in 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-merge for overridable components.
  • Unlayered CSS beats every Tailwind layer; put custom rules inside @layer components.
  • Use gap-*, not space-*.