Tailwind CSS with React
Tailwind CSS is a utility-first CSS framework: instead of writing custom class names in a stylesheet, you compose small, single-purpose utility classes directly in your JSX. In 2024–2025 Tailwind has become the dominant styling approach for new React applications, powering component ecosystems like Shadcn/ui, Radix UI Themes, Headless UI, and Catalyst.
The Utility-First Philosophy
Traditional CSS starts with a blank stylesheet and accumulates custom rules. The utility-first approach starts with a large set of predefined atomic classes and composes them. The difference becomes clear on a simple card component:
/* Traditional CSS approach */
// card.css: .card { padding: 16px; border-radius: 8px; ... }
// JSX: <div className="card">
/* Tailwind approach — no separate CSS file needed */
<div className="p-4 rounded-lg bg-white shadow-md border border-gray-100">
<h2 className="text-xl font-semibold text-gray-900 mb-2">Hello</h2>
<p className="text-gray-500 text-sm">Some description here.</p>
</div>The p-4 class applies padding: 1rem, rounded-lg applies border-radius: 0.5rem, and so on. Every class has a single, predictable CSS output. You never leave the JSX file to style the component.
Setup with Vite
# Create a new Vite + React project npm create vite@latest my-app -- --template react-ts cd my-app # Install Tailwind and its peer dependencies npm install -D tailwindcss postcss autoprefixer # Generate config files npx tailwindcss init -p
The tailwind.config.js file tells Tailwind which files to scan for class names. Classes that are not referenced in those files are stripped from the production build, keeping the final CSS tiny:
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
// Add custom colors, spacing, fonts here
colors: {
brand: {
50: '#eff6ff',
500: '#0070f3',
700: '#0051a2',
},
},
},
},
plugins: [],
}/* src/index.css — the only CSS file you need */ @tailwind base; @tailwind components; @tailwind utilities;
// src/main.tsx
import './index.css'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(<App />)A Real Card Component
Here is a product card that demonstrates Tailwind's most common patterns: layout with flexbox/grid, spacing, typography, color, hover states, and responsive design.
interface ProductCardProps {
title: string
description: string
price: number
imageUrl: string
badge?: string
}
export function ProductCard({ title, description, price, imageUrl, badge }: ProductCardProps) {
return (
<div className="group rounded-xl border border-gray-200 bg-white shadow-sm hover:shadow-md transition-shadow duration-200 overflow-hidden">
{/* Image */}
<div className="relative aspect-video overflow-hidden bg-gray-100">
<img
src={imageUrl}
alt={title}
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
{badge && (
<span className="absolute top-2 left-2 rounded-full bg-blue-600 px-2.5 py-0.5 text-xs font-semibold text-white">
{badge}
</span>
)}
</div>
{/* Content */}
<div className="p-4">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1">{title}</h3>
<p className="mt-1 text-sm text-gray-500 line-clamp-2">{description}</p>
<div className="mt-4 flex items-center justify-between">
<span className="text-2xl font-bold text-gray-900">
${price.toFixed(2)}
</span>
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 active:scale-95 transition-all">
Add to cart
</button>
</div>
</div>
</div>
)
}Conditional Classes with clsx / cn
Conditional class composition is one of Tailwind's rougher edges. Template literals become unreadable. The solution is the clsx library paired with tailwind-merge (which deduplicates conflicting Tailwind classes). The combination is typically aliased as cn:
npm install clsx tailwind-merge
// src/lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}import { cn } from '@/lib/utils'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
}
export function Button({ variant = 'primary', size = 'md', className, ...props }: ButtonProps) {
return (
<button
className={cn(
// Base styles
'inline-flex items-center justify-center rounded-lg font-semibold transition-all active:scale-95',
// Size
size === 'sm' && 'h-8 px-3 text-xs',
size === 'md' && 'h-10 px-4 text-sm',
size === 'lg' && 'h-12 px-6 text-base',
// Variant
variant === 'primary' && 'bg-blue-600 text-white hover:bg-blue-700',
variant === 'secondary' && 'border border-blue-600 text-blue-600 hover:bg-blue-50',
variant === 'ghost' && 'text-gray-600 hover:bg-gray-100',
variant === 'danger' && 'bg-red-600 text-white hover:bg-red-700',
// Disabled
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
// Allow consumer to override
className,
)}
{...props}
/>
)
}@apply: Extracting Repeated Patterns
When the same combination of utilities appears in many places, you can extract it into a named CSS class using @apply. Use this sparingly — it re-introduces the maintenance burden of custom CSS:
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn-primary {
@apply inline-flex items-center rounded-lg bg-blue-600 px-4 py-2
text-sm font-semibold text-white hover:bg-blue-700
transition-colors disabled:opacity-50;
}
.input-base {
@apply w-full rounded-lg border border-gray-300 px-3 py-2 text-sm
placeholder:text-gray-400 focus:border-blue-500 focus:outline-none
focus:ring-2 focus:ring-blue-500/20;
}
}Dark Mode
Tailwind's dark mode adds a dark: variant prefix. Configure the strategy in tailwind.config.js — 'media' respects the OS preference, 'class' toggles dark mode by adding a dark class to the <html> element:
// tailwind.config.js
export default {
darkMode: 'class', // or 'media'
// ...
}// Dark mode classes with the 'class' strategy
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 p-6 rounded-xl">
<h2 className="text-xl font-bold">Hello</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">
This card adapts to dark mode automatically.
</p>
</div>
// Toggle dark mode:
document.documentElement.classList.toggle('dark')Responsive Design
Tailwind uses mobile-first breakpoints as prefixes: sm:, md:, lg:, xl:, 2xl:. Write the mobile style first, then override at larger breakpoints:
// Single column on mobile, 2 columns on tablet, 3 on desktop
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((p) => (
<ProductCard key={p.id} {...p} />
))}
</div>
// Text size scales with screen
<h1 className="text-2xl font-bold sm:text-3xl lg:text-4xl xl:text-5xl">
Big Heading
</h1>Why Tailwind Dominates React Projects
Concern | Tailwind advantage |
|---|---|
Bundle size | Production CSS is typically 5–15 KB (all unused classes purged) |
Runtime cost | Zero — pure static classes, no JS injection |
SSR / Server Components | Fully compatible — no runtime needed |
Responsive | Built-in breakpoint prefixes, no media query boilerplate |
Dark mode | Built-in |
Design constraints | Spacing/color scale enforces consistency without a design system |
Component libraries | Shadcn/ui, Radix Themes, Headless UI all Tailwind-native |
Start every new React project with Tailwind — it is the industry default in 2025
Use
cn()(clsx + tailwind-merge) for all conditional class logicKeep
@applyminimal — prefer composing utilities in JSX over extracting to CSSConfigure your theme in
tailwind.config.jsinstead of using arbitrary values liketext-[14px]Install the Tailwind CSS IntelliSense VS Code extension for autocomplete and hover previews