Tailwind CSS v4 in Practice: Utilities, Responsive Layouts, State Variants, Dark Mode and Themes

Key takeaways

Utility classes move styling into markup, which speeds up iteration but makes long class lists harder to maintain. The post covers v4 setup and theming, reusable component patterns and animations, and explains how the JIT scanner works, so class names built by string concatenation stop disappearing.

Tailwind CSS is the most popular utility-first CSS framework. Instead of writing custom CSS, you compose small utility classes directly in your HTML. This guide covers the core concepts, responsive design, theming, and Tailwind v4 changes.

The trade-off is worth stating up front. With hand-written CSS, every new component adds new rules, so stylesheets tend to grow forever, and nobody dares delete a class because some page might still use it. With utilities, the stylesheet is bounded by the set of utilities you actually use — adding the hundredth card component reuses p-6 and rounded-xl rather than adding CSS — and deleting markup deletes its styling. The costs move into the markup: long class lists, repeated combinations that need to be extracted into components, and a vocabulary to learn. Tailwind works best in component-based code (React, Vue, Svelte, server templates with partials), where a repeated class list lives in one component instead of being copied across pages.


Installation (Tailwind v4)

npm install tailwindcss @tailwindcss/vite
// vite.config.js
import tailwindcss from '@tailwindcss/vite';

export default {
  plugins: [tailwindcss()],
};
/* src/index.css */
@import "tailwindcss";

That’s it — no tailwind.config.js needed for basic setups.

v4 also finds your templates automatically: it scans the project, skipping files listed in .gitignore, binary files and CSS files, instead of relying on a hand-maintained content array. That removes the most common v3 mistake, but introduces a new one — classes used only in a package inside node_modules or in a git-ignored folder are not scanned. Add those paths explicitly with @source "../node_modules/my-ui-lib"; in the CSS file. For projects not built with Vite, @tailwindcss/postcss (PostCSS plugin) and @tailwindcss/cli are the equivalents. If you are upgrading from v3, the official npx @tailwindcss/upgrade tool rewrites the config and renamed classes; several default utilities changed meaning (for example shadow-sm became shadow-xs, shadow became shadow-sm, and the default ring width went from 3px to 1px), so a v3 tutorial’s class list may render slightly differently in v4.


Core Utility Classes

Spacing

<div class="m-4">          <!-- margin: 1rem -->
<div class="mx-auto">      <!-- margin-left/right: auto -->
<div class="p-6">          <!-- padding: 1.5rem -->
<div class="px-4 py-2">    <!-- horizontal + vertical padding -->
<div class="mt-8 mb-4">    <!-- top + bottom margin -->

Typography

<p class="text-sm">         <!-- font-size: 0.875rem -->
<p class="text-xl">         <!-- font-size: 1.25rem -->
<p class="font-bold">       <!-- font-weight: 700 -->
<p class="text-gray-600">   <!-- color: #4b5563 -->
<p class="leading-relaxed"> <!-- line-height: 1.625 -->
<p class="tracking-wide">   <!-- letter-spacing: 0.025em -->
<p class="uppercase">       <!-- text-transform: uppercase -->
<p class="truncate">        <!-- overflow: hidden + ellipsis -->

Colors

<div class="bg-blue-500 text-white">    <!-- background + text color -->
<div class="border border-gray-200">   <!-- border -->
<div class="text-red-600">             <!-- error text -->

Colors follow a 50–950 scale: gray-50 is lightest, gray-950 is darkest. The hex values in the comments above are Tailwind v3’s; v4 defines its palette in the OKLCH color space, so the generated values differ slightly while the names stay the same.

Spacing utilities are multiples of a single base unit: p-4 means 4 × 0.25rem = 1rem. In v4 this is computed from one --spacing variable, so any integer works (p-13, mt-22) without configuration, while arbitrary values in square brackets (mt-[13px]) are the escape hatch for one-off measurements. Reaching for brackets often is a sign the design is drifting away from the scale, which is exactly the consistency utilities are meant to protect.


Responsive Design

Tailwind uses mobile-first breakpoints with prefixes:

PrefixMin-width
(none)0px
sm:640px
md:768px
lg:1024px
xl:1280px
2xl:1536px
<!-- 1 column on mobile, 2 on tablet, 3 on desktop -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
  <div class="p-4 bg-white rounded-lg shadow">Card 1</div>
  <div class="p-4 bg-white rounded-lg shadow">Card 2</div>
  <div class="p-4 bg-white rounded-lg shadow">Card 3</div>
</div>

<!-- Hidden on mobile, visible on desktop -->
<nav class="hidden lg:flex gap-6">
  <a href="/">Home</a>
  <a href="/about">About</a>
</nav>

<!-- Full width on mobile, fixed width on desktop -->
<div class="w-full md:w-96">
  Sidebar
</div>

“Mobile-first” means an unprefixed class applies at every width, and a prefixed one applies from that width upward (md: compiles to @media (width >= 48rem)). The most common misunderstanding is reading sm: as “on small screens”: sm:hidden hides the element on everything 640px and wider, and leaves it visible on phones. To target only a range, combine a breakpoint with a max variant, such as md:max-lg:flex. Write the phone layout first without prefixes, then add prefixes for what changes on larger screens — the class lists stay shorter that way.


Flexbox and Grid

<!-- Flexbox row, centered, spaced -->
<div class="flex items-center justify-between gap-4">
  <span>Left</span>
  <span>Right</span>
</div>

<!-- Flex column, center everything -->
<div class="flex flex-col items-center justify-center min-h-screen">
  <h1>Centered content</h1>
</div>

<!-- CSS Grid, auto-fill columns -->
<div class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-4">
  <!-- Auto-responsive grid -->
</div>

The last example uses an arbitrary value: anything inside square brackets is copied into the CSS, with underscores standing in for spaces because class names cannot contain spaces. That grid adjusts its column count to the container width without any breakpoints, which is often a better fit for card lists than the fixed md:grid-cols-2 lg:grid-cols-3 pattern, since it responds to the space actually available (for example inside a sidebar layout) rather than to the viewport. A common flexbox surprise is text refusing to shrink or truncate inside a flex child; the child’s default min-width: auto prevents it, and adding min-w-0 to the flex item fixes it.


State Variants

<!-- Hover -->
<button class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">
  Hover me
</button>

<!-- Focus -->
<input class="border focus:outline-none focus:ring-2 focus:ring-blue-500 rounded px-3 py-2" />

<!-- Active -->
<button class="bg-gray-100 active:bg-gray-200 px-4 py-2">Click</button>

<!-- Disabled -->
<button class="bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" disabled>
  Disabled
</button>

<!-- Group hover (parent hover affects child) -->
<div class="group flex items-center gap-2 cursor-pointer">
  <span>Icon</span>
  <span class="opacity-0 group-hover:opacity-100 transition">Details</span>
</div>

Each variant becomes the corresponding CSS selector or media query around the same declaration, which is what inline style attributes cannot do. Two practical notes. In v4, hover: is wrapped in @media (hover: hover), so hover styles no longer “stick” after a tap on touch devices — occasionally surprising when testing on a phone. And focus:outline-none removes the focus indicator that keyboard users rely on; if you remove it, replace it with a visible focus-visible:ring-2, and in v4 prefer outline-hidden, which keeps an outline in Windows high-contrast mode (v4’s outline-none now sets outline-style: none outright). group works for any ancestor marked group; nested groups can be told apart with names such as group/card and group-hover/card:.


Dark Mode

<!-- dark: follows the OS setting by default; see below for a .dark class toggle -->
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
  <h1 class="text-2xl font-bold">Supports dark mode</h1>
</div>
/* v4: switch dark: from the OS preference to a .dark class */
@custom-variant dark (&:where(.dark, .dark *));
// Toggle dark mode programmatically
document.documentElement.classList.toggle('dark');

By default, dark: uses the prefers-color-scheme: dark media query, so it follows the operating system and the JavaScript toggle has no effect. The @custom-variant line switches it to the class strategy; :where() keeps the selector’s specificity at zero so dark: classes do not accidentally outrank other utilities. With a manual toggle, store the choice (for example in localStorage) and apply the class from a small inline script in <head> before the page renders. Applying it later, from your app bundle, produces the familiar flash of the light theme on every page load.


Custom Theme (Tailwind v4)

In v4, theming moves to CSS with @theme:

@import "tailwindcss";

@theme {
  --color-brand-50: #eff6ff;
  --color-brand-500: #3b82f6;
  --color-brand-900: #1e3a5f;

  --font-sans: 'Inter', sans-serif;
  --font-mono: 'JetBrains Mono', monospace;

  --radius-xl: 1rem;

  --spacing-18: 4.5rem;
}
<!-- Use custom tokens -->
<div class="bg-brand-500 text-brand-50 rounded-xl p-18">
  Custom themed card
</div>

Each variable in @theme does two things: it creates utilities from the variable’s namespace (--color-brand-500 yields bg-brand-500, text-brand-500, border-brand-500 and so on) and it is emitted as a real CSS custom property on :root, so the same value is available as var(--color-brand-500) in plain CSS or JavaScript. The namespace prefix is what matters: a variable named --brand-500 without --color- produces no utilities. Values added this way extend the defaults; to drop the default palette entirely, reset the namespace first with --color-*: initial;. The --spacing-18 line is not strictly needed in v4, where p-18 already works from the spacing multiplier, but naming a value is still useful when it is a deliberate design token rather than an arbitrary number.


Component Patterns

Button component (React)

type ButtonProps = {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

const variants = {
  primary: 'bg-blue-600 hover:bg-blue-700 text-white',
  secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-900',
  danger: 'bg-red-600 hover:bg-red-700 text-white',
};

const sizes = {
  sm: 'px-3 py-1.5 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-3 text-lg',
};

export function Button({ variant = 'primary', size = 'md', className = '', ...props }: ButtonProps) {
  return (
    <button
      className={`
        ${variants[variant]}
        ${sizes[size]}
        rounded-lg font-medium transition-colors
        focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
        disabled:opacity-50 disabled:cursor-not-allowed
        ${className}
      `}
      {...props}
    />
  );
}

Keeping the variant and size classes in lookup objects, with every class written out in full, is what keeps them visible to Tailwind’s scanner — building bg-${color}-600 would break, as the FAQ explains. The weak spot in this component is the className prop. If a caller passes className="px-8", the element ends up with both px-4 and px-8, and which one wins is decided by the order of the rules in the generated stylesheet, not by the order of the class names in the attribute. The override may or may not work depending on the utility. That is the problem cn() with tailwind-merge solves, described below; in a real component you would write className={cn(variants[variant], sizes[size], '...', className)}.

Card component

<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-700 p-6 hover:shadow-md transition-shadow">
  <img class="w-full h-48 object-cover rounded-xl mb-4" src="..." alt="..." />
  <h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Card Title</h2>
  <p class="text-gray-500 dark:text-gray-400 text-sm leading-relaxed">Description text</p>
</div>

Animations and Transitions

<!-- Built-in transitions -->
<div class="transition-all duration-200 ease-in-out">...</div>

<!-- Transform on hover -->
<div class="hover:scale-105 hover:-translate-y-1 transition-transform duration-200">Card</div>

<!-- Tailwind animations -->
<div class="animate-spin">⟳</div>       <!-- spinning loader -->
<div class="animate-pulse bg-gray-200 rounded h-4 w-32"></div>  <!-- skeleton -->
<div class="animate-bounce">↓</div>

Prefer transition-transform or transition-colors over transition-all: animating every property makes the browser watch layout-affecting properties like width and height too, and it can cause unintended animations when unrelated styles change. Transforms and opacity can be animated on the compositor without re-running layout, so they stay smooth on low-end devices. For users who have asked their OS to reduce motion, the motion-safe: and motion-reduce: variants let you disable decorative animation such as animate-bounce.


Using cn() for Conditional Classes

Install clsx + tailwind-merge:

npm install clsx tailwind-merge
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs) {
  return twMerge(clsx(inputs));
}

// Usage
<div className={cn(
  'rounded-lg p-4',
  isActive && 'bg-blue-100 border-blue-500',
  isError && 'bg-red-100 border-red-500',
  className  // allow override from parent
)} />

twMerge resolves conflicts when the same property appears in multiple classes (e.g., p-4 vs p-6).

The two libraries do different jobs. clsx only joins strings and drops falsy values, so isActive && '...' disappears when isActive is false. tailwind-merge understands Tailwind’s class groups and keeps the last class for each property, so cn('p-4', 'p-6') returns p-6 and a parent’s override reliably wins. It also knows that px-8 should replace the horizontal part of p-4 but keep its vertical padding. The costs are a runtime library and parsing on every render, which is negligible for most UIs, and one configuration step if you define custom utilities: tailwind-merge only knows your custom class groups if you tell it via extendTailwindMerge, otherwise it may keep two conflicting custom classes. Use a tailwind-merge major version that matches your Tailwind version (v3 of the library for Tailwind v4).


Deep dive: JIT engine, PostCSS, content scanning, tokens

Memorizing utilities is only half the story. Understanding how the build turns class names into CSS explains issues like “works locally, wrong in production” and “dynamic classes disappear after deploy.” The model below applies to both v3-style tailwind.config + PostCSS stacks and v4 @import "tailwindcss" setups (often paired with Vite and Lightning CSS).

JIT (Just-In-Time) compilation

Tailwind’s default mode is JIT: at build time it walks the files matched by content (v3) or the file set your bundler feeds in, extracts strings that look like Tailwind class candidates, and emits only the CSS rules needed for those candidates. It is not a runtime engine in the browser that assembles utilities on the fly. That matters because string concatenation such as 'text-' + color may never appear as a complete class name to the scanner, so no rule is generated—use a finite map of full class names, safelist, or a design-system enum.

In development, incremental rebuilds keep feedback fast; in production you get a single deterministic stylesheet from the same pipeline.

PostCSS plugin architecture

In classic PostCSS setups, Tailwind runs as a PostCSS plugin: CSS is parsed to an AST, plugins run in order, then the tree is serialized back to text. Tailwind expands @tailwind directives, resolves @apply / @layer against your theme and candidate list, and hands off to tools like Autoprefixer for vendor prefixes. Order matters—run Tailwind before generic downstream transforms that assume “final” declarations, unless your toolchain docs say otherwise. In v4, first-party Vite integration may bypass a manual postcss.config, but the mental model stays: scan → generate → emit, with optional Lightning CSS minification.

“Purge” vs content-driven generation and tree-shaking

Older workflows used PurgeCSS-style delete unused rules from a huge prebuilt file. Modern Tailwind instead does not emit unused utilities in the first place (for statically discoverable classes). The analogy to JS tree-shaking is imperfect, but the outcome is similar: small final CSS when your content globs cover every template that can introduce classes.

Treat content (v3) or the automatically detected sources plus @source (v4) as a whitelist of scan roots. If a class only appears under a path you forgot—shared UI packages, Storybook-only files, rarely built apps in a monorepo—that utility may be missing in production. Prefer the vocabulary content scanning and candidate generation over vague “purge” when onboarding teammates.

The failure mode I would check first when “a style works in dev but not in production” is exactly this: the class exists in the dev CSS because some other file happens to use it, or because a dev-only file is scanned, and the production build scans a different set. Searching the built CSS file for the class name settles it in seconds — if the rule is missing, it is a scanning problem, not a specificity problem.

Design token system

Primitive tokens (palette steps, spacing scale) map cleanly to theme / theme.extend (v3) or @theme (v4) as build-time constants. Semantic tokens (surface, danger) help you swap themes without sprinkling raw palette steps everywhere. When values must change after deploy (multi-tenant branding), CSS custom properties as the source of truth with Tailwind bridging via bg-[var(--color-surface)] is a common production pattern—document fallbacks and scope (:root vs [data-theme]). In v4 the same thing can be written more briefly as bg-(--color-surface), and because every @theme token is already a CSS variable, overriding --color-brand-500 inside a [data-theme="tenant-b"] selector re-themes every bg-brand-500 below it without rebuilding the CSS.

Production patterns checklist

  • Include package sources in content for internal UI libraries.
  • Safelist or explicit class maps for CMS-driven or dynamically chosen classes.
  • tailwind-merge + clsx (or cn) to resolve conflicting utilities in conditional UIs.
  • Share presets from a design-system package, but still merge its paths into the app content.
  • Run the same build in CI as locally so cwd and env-based path differences do not hide scan gaps.

For a longer treatment of tokens, layers, and library boundaries, see Tailwind CSS: Components, Tokens, and a Practical Design System.


Frequently Asked Questions (FAQ)

Q. Why are classes I build with string concatenation missing from the output CSS?

A. Tailwind generates CSS by scanning your source files for complete class names as plain text; it does not run your code. A class assembled at runtime, like bg-${color}-500, never appears in full, so no rule is generated for it. Map values to full class strings instead (for example, an object with bg-red-500 and bg-blue-500), or use a safelist for the rare cases where names truly come from data.