Tailwind CSS in React: Utility-First Styling That Actually Scales
Skip the stylesheet ping-pong. Tailwind puts your styles next to your markup, keeps bundles small, and makes responsive and dark-mode design mechanical rather than creative.

Tailwind CSS is a utility-first CSS framework. Instead of writing classes like .card-header and then filling a stylesheet with the rules behind them, you compose small single-purpose classes—flex, p-4, text-sm, bg-white—directly in your JSX. The idea sounds messy until you try it; then it starts to feel obvious.
Installing Tailwind in a Vite + React project
Tailwind v3 integrates through PostCSS, so the installation is two packages and a config file:
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -ptailwind.config.js controls which files Tailwind scans for class names. Set content to every file that might contain Tailwind classes:
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}Then add the three Tailwind directives to your global CSS entry-point (usually src/index.css):
@tailwind base;
@tailwind components;
@tailwind utilities;That is the entire setup. Run npm run dev and Tailwind is active.
The core mental model: spacing and sizing
Tailwind uses a base-4 scale. p-1 is 0.25rem, p-4 is 1rem, p-8 is 2rem, and so on. The same scale applies to margins (m-*), widths (w-*), heights (h-*), gaps (gap-*), and border-radius (rounded-*). Once the scale is in your muscle memory, sizing decisions become fast and consistent.
A typical card component:
function Card({ title, body }: { title: string; body: string }) {
return (
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<h2 className="mb-2 text-lg font-semibold text-gray-900">{title}</h2>
<p className="text-sm text-gray-600">{body}</p>
</div>
);
}No separate CSS file. No class-name collisions. The styles live with the component that uses them.
Responsive design with breakpoint prefixes
Tailwind ships with five breakpoints: sm (640 px), md (768 px), lg (1024 px), xl (1280 px), and 2xl (1536 px). Prefix any utility with a breakpoint to apply it only at that width and above:
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item) => <Card key={item.id} {...item} />)}
</div>One column on mobile, two columns from 640 px, three columns from 1024 px—entirely in the className, no media queries in a stylesheet.
Dark mode
Tailwind's dark variant activates when the prefers-color-scheme media query matches or when a parent element has the dark class (switch between the two with darkMode: 'media' or darkMode: 'class' in tailwind.config.js).
Class-based dark mode lets you toggle themes manually:
// tailwind.config.js
export default {
darkMode: 'class',
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
// …
}Then prefix any utility with dark: to apply it in dark mode:
<div className="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<p className="text-sm text-gray-600 dark:text-gray-400">
This text adapts to the theme.
</p>
</div>Toggle the dark class on <html> to switch themes:
function toggleDark() {
document.documentElement.classList.toggle('dark');
}Conditional classes with clsx
JSX string concatenation gets ugly fast. The clsx library (or its typed twin clsx) solves it cleanly:
npm install clsximport clsx from 'clsx';
type ButtonProps = {
variant?: 'primary' | 'ghost';
disabled?: boolean;
children: React.ReactNode;
onClick?: () => void;
};
function Button({ variant = 'primary', disabled, children, onClick }: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={clsx(
'rounded-lg px-4 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2',
{
'bg-indigo-600 text-white hover:bg-indigo-700': variant === 'primary',
'border border-gray-300 text-gray-700 hover:bg-gray-50': variant === 'ghost',
'opacity-50 cursor-not-allowed': disabled,
}
)}
>
{children}
</button>
);
}A common pattern in larger projects is a cn() helper that combines clsx with tailwind-merge to prevent conflicting utilities (p-4 and p-6 on the same element) from both appearing in the output:
npm install clsx tailwind-merge// src/lib/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}Use cn() everywhere you'd use clsx(). The merge step ensures that when a consumer overrides px-4 with px-6, only px-6 ends up in the DOM.
When to use @apply
The @apply directive lets you extract repeated utility patterns into a named CSS class:
/* src/index.css */
@layer components {
.btn-primary {
@apply rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700;
}
}Reach for it only when the same combination appears across many files and a component abstraction is not the right tool (global button resets, prose typography). Inside React, a component is almost always the better choice—@apply moves your styles back into a stylesheet and breaks the colocation benefit.
Production bundle size
Tailwind's JIT compiler (on by default since v3) scans your content paths and generates only the utility classes that actually appear in your code. A typical production stylesheet is 5–20 kB before gzip—far smaller than a hand-rolled stylesheet that has accumulated unused rules over years of development.
The only requirement is that class names be statically detectable: write complete strings like text-red-500, not assembled strings like `text-${color}-500`. Dynamic lookups like that are invisible to the scanner, so the class will be missing in production.
Summary
- Install via PostCSS; add three
@tailwinddirectives to your global CSS. - Compose utilities directly in JSX—spacing, colors, typography, borders all share the same scale.
- Prefix any utility with a breakpoint (
sm:,md:,lg:) for responsive layouts. - Use the
dark:prefix withdarkMode: 'class'for a toggleable theme. - Use
clsxor thecn()helper (clsx+tailwind-merge) for conditional and overrideable class names. - Avoid
@applyinside React; a component is usually the right abstraction.