Tailwind CSS (React integration patterns)
In this chapter, you learn with a focus on patterns for combining Tailwind CSS, which has become the de facto standard in React development, with React.
The basics of Tailwind (setup, utility-first, Flexbox / Grid / Typography / Colors, etc.) are covered systematically in the Tailwind CSS Guide (all 18 chapters).
This chapter covers only React-specific topics (variant design / clsx / tailwind-merge / cn / shadcn/ui). If Tailwind itself is new to you, we recommend reading the basics and layout chapters of the Tailwind Guide first. Until that guide is published, the setup essentials in this chapter are sufficient.
What you learn in this chapter
- The setup essentials for using Tailwind with React (supplemented with links to the Tailwind Guide)
- Patterns for specifying dynamic classes on
className - Designing a reusable button with
variant×size - Organizing conditional classes with
clsx - Resolving class conflicts with
tailwind-merge - The
cnutility (the standard shadcn/ui pattern)
Setup essentials
In a Vite + React project, use the @tailwindcss/vite plugin (from v4 onward, tailwind.config.js is unnecessary).
npm install tailwindcss @tailwindcss/vite
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})
@import "tailwindcss";
The detailed setup steps (for PostCSS / CLI / CDN / the VS Code extension / Prettier integration / Next.js / Nuxt) are explained in the setup chapter of the Tailwind CSS Guide.
The basics of writing dynamic classes on className in React
In JSX, you use the className attribute instead of class. The basic form of Tailwind + React is to not write <style> and instead concatenate utility classes into className.
function ProfileCard({ name, isOnline }: { name: string; isOnline: boolean }) {
return (
<div className="flex items-center gap-3 p-4 bg-white rounded-lg shadow-sm">
<div className={`w-10 h-10 rounded-full ${isOnline ? 'bg-green-500' : 'bg-gray-300'}`} />
<span className="font-medium text-gray-900">{name}</span>
</div>
)
}
A ternary operator in a template literal is OK in moderation, but it gets hard to read as conditions increase. We organize it with clsx / cn in the next section.
Designing a reusable button with variant × size
The pattern of receiving variant and size as Props and looking up the corresponding classes from an object is the most commonly used in Tailwind + React.
type ButtonProps = {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
children: React.ReactNode
onClick?: () => void
}
const baseClasses = 'rounded-sm font-medium transition-colors focus:outline-hidden focus:ring-2'
const sizeClasses = {
sm: 'px-2 py-1 text-sm',
md: 'px-4 py-2',
lg: 'px-6 py-3 text-lg',
} satisfies Record<NonNullable<ButtonProps['size']>, string>
const variantClasses = {
primary: 'bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-300',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-300',
danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-300',
} satisfies Record<NonNullable<ButtonProps['variant']>, string>
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
children,
onClick,
}: ButtonProps) {
const disabledClasses = disabled ? 'opacity-50 cursor-not-allowed' : ''
return (
<button
className={`${baseClasses} ${sizeClasses[size]} ${variantClasses[variant]} ${disabledClasses}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
)
}
Key points:
satisfies Record<...>enforces key exhaustiveness at the type level. Forgetting to add a newvariantcauses a compile error- Separating
baseClasses/sizeClasses/variantClassesmakes responsibilities explicit - When it grows large, migrate to
cva(class-variance-authority) in the next section
clsx — clean up conditional classes
clsx is a simple utility that combines class names based on conditions. Falsy values (false / null / undefined / 0 / "") are ignored.
npm install clsx
import clsx from 'clsx'
function Button({ isActive, isDisabled, children }: {
isActive: boolean
isDisabled: boolean
children: React.ReactNode
}) {
return (
<button
className={clsx(
'px-4 py-2 rounded-sm',
isActive && 'bg-blue-500 text-white',
!isActive && 'bg-gray-200 text-gray-800',
isDisabled && 'opacity-50 cursor-not-allowed'
)}
disabled={isDisabled}
>
{children}
</button>
)
}
You can also use object notation, such as clsx(['base', { 'bg-blue-500': isActive }]). As a more lightweight, faster drop-in replacement for the similar library classnames (239B per the official README), it is the de facto standard in the Tailwind community.
tailwind-merge — resolve class conflicts
In Tailwind, when classes for the same property are duplicated, the result depends on the order in which the CSS is generated, and last-wins is not guaranteed.
// Expected: bg-red-500 is applied
// Actual: depending on the CSS order, bg-blue-500 may win
<div className="bg-blue-500 bg-red-500" />
This frequently happens especially in situations where "you want to override classes passed by the parent in the child" (extending a reusable component).
npm install tailwind-merge
import { twMerge } from 'tailwind-merge'
twMerge('bg-blue-500', 'bg-red-500')
// => 'bg-red-500'
twMerge('px-2 py-1', 'p-4')
// => 'p-4' (px-2 py-1 is absorbed into p-4)
The cn utility — clsx + tailwind-merge
In practice, the standard is to create a cn function that combines clsx (conditional assembly) and tailwind-merge (conflict resolution). shadcn/ui also adopts this pattern.
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}
import { cn } from '@/lib/utils'
type CardProps = {
variant?: 'default' | 'highlighted'
className?: string
children: React.ReactNode
}
export function Card({ variant = 'default', className, children }: CardProps) {
return (
<div
className={cn(
'p-4 rounded-lg shadow-sm',
variant === 'default' && 'bg-white',
variant === 'highlighted' && 'bg-blue-50 ring-2 ring-blue-500',
className // the className passed by the parent wins last
)}
>
{children}
</div>
)
}
// The calling side
<Card variant="default" className="bg-gray-100"> {/* bg-white → bg-gray-100 reliably wins */}
...
</Card>
class-variance-authority (cva) — design variants declaratively
When the variant × size pattern becomes complex, you can write it declaratively with the dedicated library class-variance-authority (cva).
npm install class-variance-authority
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'rounded-sm font-medium transition-colors focus:outline-hidden focus:ring-2',
{
variants: {
variant: {
primary: 'bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-300',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-300',
danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-300',
},
size: {
sm: 'px-2 py-1 text-sm',
md: 'px-4 py-2',
lg: 'px-6 py-3 text-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button className={cn(buttonVariants({ variant, size }), className)} {...props} />
)
}
The good things about cva:
- Type inference:
VariantProps<typeof buttonVariants>auto-generates the types ofvariant/size - Default values: declare the behavior when omitted with
defaultVariants - compoundVariants: you can also describe styles for combinations of multiple variants
- It is a de facto standard, adopted inside shadcn/ui as well
Integration with shadcn/ui
shadcn/ui is a component library based on Tailwind CSS, characterized by a format that copies components into your own project. Instead of npm install, you bring the source into your project locally, as in npx shadcn@latest add button.
Benefits:
- You are not bound by the version compatibility of a library (you own it as your own code)
- Changing colors with Tailwind's
@themechanges the entire theme - It uses
cn/cvainternally, so the knowledge in this chapter applies directly
When adopting shadcn/ui, instead of writing the Button in this chapter yourself, the common approach is to generate it with npx shadcn@latest add button and add variants as needed.
Responsive and state variants (from a React perspective)
Tailwind's responsive (sm: / md: / lg:) and state variants (hover: / focus: / active: / disabled:) can be used as-is in React. The details are covered in each chapter of the Tailwind CSS Guide:
- Responsive design — the basics of
sm:md: - State variants —
hoverfocusgroup-*peer-*has-* - Dark mode — the
dark:variant and switching methods
As a React-specific note, combining a button with the disabled attribute with a Tailwind variant such as disabled:opacity-50 lets you declaratively switch the style of an element that is disabled at the HTML level.
<button
className="px-4 py-2 bg-blue-500 text-white rounded-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
Submit
</button>
Caveats with SSR / RSC
When using Tailwind with RSC (React Server Components), such as the Next.js App Router, utility classes are output to the HTML as-is during server rendering, so no special consideration is needed. However, when you share a function that uses clsx / cn across both client components and server components, you need to be conscious of the propagation of the 'use client' directive (a pure string-concatenation function is server safe).
Summary
- The basics are consolidated in the Tailwind CSS Guide; this chapter focuses on React integration patterns
- Be type-safe with the
variant×sizeobject-lookup pattern +satisfies Record - Use
clsxfor conditional classes,tailwind-mergefor conflict resolution, andcnto unify the two - When it grows large, write it declaratively with
class-variance-authority(cva) - shadcn/ui is an ecosystem where the knowledge in this chapter applies directly
What to read next
- Tailwind CSS Guide — learn Tailwind itself systematically
- Portals and Modals — implement a modal with createPortal