
Using Tailwind CSS Effectively in React Projects
Tailwind is easy to start with and easy to make a mess with. The first few components feel great. Six months later you have 40-class strings copied across files, a button that ignores the className you pass it, a color that only works in light mode, and a bg-${color}-500 that never shows up in production.
None of that is Tailwind's fault. It's a sign the project skipped a few patterns that make utility classes scale in a component-based app. React gives you components as the unit of reuse, and Tailwind works best when you lean on that instead of fighting it.
This post walks through a clean Tailwind v4 setup in a React project, then the patterns that keep it maintainable: design tokens with @theme, a cn() helper for merging classes, variant APIs with cva, state-driven styling with data-* and group variants, dark mode, and the mistakes that cause most Tailwind headaches.
Setting Up Tailwind v4 With Vite
Tailwind v4 dropped the JavaScript config file as the default and moved configuration into CSS. For a Vite React app, install the core package and the Vite plugin:
npm install tailwindcss @tailwindcss/vite
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});
Then import Tailwind at the top of your main stylesheet:
/* src/index.css */
@import "tailwindcss";
That's it. There's no content array to maintain; v4 detects your source files automatically and ignores anything in .gitignore. If you need to scan a folder it would otherwise skip, like a component package in node_modules, add it with @source:
@import "tailwindcss";
@source "../node_modules/@acme/ui/dist";
If you're starting a fresh project, building React apps with Vite covers the rest of the setup.
Define Your Design Tokens With @theme
The single most useful habit is putting your brand's colors, fonts, and spacing tweaks in @theme instead of scattering arbitrary values like bg-[#4f46e5] through components.
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-brand-50: oklch(0.97 0.02 265);
--color-brand-500: oklch(0.58 0.2 265);
--color-brand-600: oklch(0.51 0.21 265);
--color-brand-700: oklch(0.44 0.19 265);
--color-surface: oklch(1 0 0);
--color-ink: oklch(0.21 0.02 265);
--color-muted: oklch(0.55 0.02 265);
--radius-card: 0.875rem;
}
Each variable in a known namespace becomes utilities. --color-brand-600 gives you bg-brand-600, text-brand-600, border-brand-600, and so on. --radius-card gives you rounded-card. --font-sans replaces the default sans stack.
The variables are also real CSS custom properties on :root, so you can use them anywhere: in a CSS Module, an inline style, or a chart library config. That's the big advantage over the old JavaScript config.
Semantic tokens like surface, ink, and muted are worth adding early. Components that use bg-surface text-ink instead of bg-white text-gray-900 get dark mode almost for free, as you'll see below.
The cn() Helper
Every reusable component needs to accept a className from its parent. The problem is that Tailwind classes don't override each other by position in the string. If your button has px-4 and the parent passes px-8, which one wins depends on the order those rules appear in the generated CSS, not in your className.
The standard fix is combining clsx (for conditional classes) with tailwind-merge (for resolving conflicts):
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));
}
twMerge understands Tailwind's class groups, so cn("px-4 py-2", "px-8") returns "py-2 px-8". It also handles conditional values:
import { cn } from "@/lib/cn";
type CardProps = React.ComponentProps<"div"> & {
highlighted?: boolean;
};
export function Card({ highlighted, className, ...rest }: CardProps) {
return (
<div
className={cn(
"rounded-card border border-gray-200 bg-surface p-6 shadow-sm",
highlighted && "border-brand-500 ring-2 ring-brand-500/20",
className,
)}
{...rest}
/>
);
}
Put className last so the caller always gets the final say. That one rule eliminates most "why won't my override work" bugs.
Variant APIs With cva
Once a component has variants and sizes, string concatenation gets ugly. class-variance-authority (cva) gives you a declarative way to map props to classes, with TypeScript types derived from the definition.
npm install class-variance-authority
// src/components/Button.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-brand-600 text-white hover:bg-brand-700",
secondary: "bg-brand-50 text-brand-700 hover:bg-brand-50/70",
ghost: "text-ink hover:bg-gray-100",
danger: "bg-red-600 text-white hover:bg-red-700",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4",
lg: "h-12 px-6 text-lg",
icon: "size-10",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
},
);
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants>;
export function Button({ variant, size, className, ...rest }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...rest}
/>
);
}
Now variant and size are typed unions, invalid values are compile errors, and every class string lives in one place. cva also supports compoundVariants for combinations, like a special style only when variant is ghost and size is icon.
This is exactly the pattern shadcn/ui uses for its components. If you're heading that way, getting started with shadcn/ui shows how it fits together.
Never Build Class Names Dynamically
Tailwind generates CSS by scanning your source for complete class names as plain text. It never runs your code. So this doesn't work:
// Broken: Tailwind never sees "bg-green-500" in the source
function Badge({ color }: { color: "green" | "red" | "amber" }) {
return <span className={`bg-${color}-500 text-white`}>{color}</span>;
}
It might even appear to work in development if the same class happens to be used elsewhere, then break in another page. Map props to complete strings instead:
const badgeColors = {
green: "bg-green-100 text-green-800",
red: "bg-red-100 text-red-800",
amber: "bg-amber-100 text-amber-800",
} as const;
type BadgeProps = {
color: keyof typeof badgeColors;
children: React.ReactNode;
};
export function Badge({ color, children }: BadgeProps) {
return (
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${badgeColors[color]}`}
>
{children}
</span>
);
}
For values that come from data, like a user-chosen hex color, use a CSS variable with an inline style and reference it with Tailwind's variable shorthand:
export function Swatch({ hex }: { hex: string }) {
return (
<div
className="size-8 rounded-full bg-(--swatch) ring-1 ring-black/10"
style={{ "--swatch": hex } as React.CSSProperties}
/>
);
}
bg-(--swatch) compiles to background-color: var(--swatch). The class is static, only the variable changes.
If you truly need classes generated that never appear in source (for example, class names stored in a CMS), v4.1 and later support safelisting with @source inline("bg-red-500 bg-green-500").
Style State With Variants, Not JavaScript
A lot of conditional className logic can move into CSS by styling based on attributes the component already sets. Tailwind has variants for ARIA attributes, data attributes, and parent or sibling state.
aria-and data- Variants
type TabProps = {
selected: boolean;
children: React.ReactNode;
onSelect: () => void;
};
export function Tab({ selected, children, onSelect }: TabProps) {
return (
<button
role="tab"
aria-selected={selected}
onClick={onSelect}
className="border-b-2 border-transparent px-4 py-2 text-muted aria-selected:border-brand-600 aria-selected:text-ink"
>
{children}
</button>
);
}
The accessibility attribute and the visual state can't drift apart, because one drives the other. The same works with data-* attributes, which is how libraries like Radix expose state: data-[state=open]:rotate-180 rotates a chevron when the parent sets data-state="open".
group and peer
When a child needs to react to a parent's hover or focus, mark the parent with group and use group-hover: on the child:
export function ProjectRow({ name, href }: { name: string; href: string }) {
return (
<a
href={href}
className="group flex items-center justify-between rounded-lg px-4 py-3 hover:bg-gray-50"
>
<span className="font-medium text-ink">{name}</span>
<span className="text-muted opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
Open →
</span>
</a>
);
}
peer does the same for siblings, which is handy for form validation styles:
export function EmailField() {
return (
<label className="grid gap-1">
<span className="text-sm font-medium">Email</span>
<input
type="email"
required
className="peer rounded-md border px-3 py-2 user-invalid:border-red-500"
/>
<span className="hidden text-sm text-red-600 peer-user-invalid:block">
Enter a valid email.
</span>
</label>
);
}
user-invalid maps to the :user-invalid pseudo-class, which only matches after the user has interacted with the field, so errors don't appear on page load. No React state needed. For anything beyond basic checks, see client-side form validation patterns.
Container Queries
v4 has container queries built in. Mark a parent with @container and use @sm:, @md:, and so on for children. That lets a card change layout based on its own width rather than the viewport, which is what you usually want for reusable components:
export function ProductCard({
title,
image,
}: {
title: string;
image: string;
}) {
return (
<div className="@container">
<article className="flex flex-col gap-4 @md:flex-row">
<img
src={image}
alt=""
className="aspect-video w-full rounded-lg object-cover @md:w-48"
/>
<h3 className="text-lg font-semibold">{title}</h3>
</article>
</div>
);
}
Dark Mode
By default, the dark: variant follows the operating system's prefers-color-scheme. To control it with a class (for a toggle), override the variant in your CSS:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
You can now write dark:bg-gray-900 everywhere. But a cleaner approach is to make your semantic tokens switch values, so components don't need dark: at all:
@theme {
--color-surface: oklch(1 0 0);
--color-ink: oklch(0.21 0.02 265);
}
@layer base {
.dark {
--color-surface: oklch(0.2 0.02 265);
--color-ink: oklch(0.95 0.01 265);
}
}
Every bg-surface and text-ink updates when .dark is on the root element. The full toggle, including persistence and avoiding the flash on load, is in building a dark mode toggle in React.
Keeping Class Lists Readable
Long class strings are the most common complaint. A few habits help:
- Install the Prettier plugin.
prettier-plugin-tailwindcsssorts classes in a consistent order, so related utilities end up together and diffs stay small. - Extract components, not classes. If the same 15 classes appear in three places, that's a component waiting to happen. Make a
CardorBadge, not a.cardclass. - Use
@applysparingly. It's fine for styling markup you don't control, like Markdown output or third-party widgets. For your own components, it hides styles in another file and brings back the naming problem Tailwind removed. - Split by concern when it helps. In
cn(), you can pass layout, color, and state classes as separate strings. It's the same output, but easier to scan.
Common Mistakes With Tailwind in React
- Interpolating class names.
text-${size}is never generated. Use lookup objects with complete class names. - Putting
classNamefirst incn(). The caller's overrides lose. Always merge it last. - Skipping
tailwind-merge. Without it, conflicting classes resolve by stylesheet order and overrides become unpredictable. - Hardcoding colors.
bg-white text-gray-900in every component makes dark mode and rebranding painful. Use semantic tokens. - Overusing arbitrary values.
mt-[13px]once is fine. Dozens of them mean your spacing scale or tokens are missing something. - Using
@applyto recreate BEM classes. You end up with the downsides of both approaches. - Toggling classes with state when an attribute exists. If you already set
aria-expandedordisabled, style off that with variants instead of duplicating logic.
Frequently Asked Questions (FAQ) About Using Tailwind CSS in React
No. Tailwind v4 is configured in CSS with @import, @theme, @source, and @custom-variant. A JavaScript config is still supported through the @config directive for projects migrating from v3, but new projects don't need one.
Tailwind generates CSS by scanning source files for complete class names as text. A class assembled at runtime, like a template string with a color variable, never appears in the source, so no CSS is generated for it. Map props to full class names in an object, or use a CSS variable for truly dynamic values.
It's a small library that resolves conflicting Tailwind classes, keeping the last one in each group. You need it in any reusable component that accepts a className prop, otherwise a parent's override may lose to the component's default depending on stylesheet order.
Rarely. In React the component is the unit of reuse, so repeated class lists should become components. Use @apply for styling content you don't control, such as rendered Markdown or third-party widgets, where you can't add classes to the markup.
No. Tailwind outputs a single static stylesheet containing only the utilities you use, and that file stays small because utilities are shared across components. There's no runtime JavaScript, so it's one of the lightest styling options for React.
Many libraries, like shadcn/ui and Headless UI, are built for Tailwind. For libraries that ship compiled components in node_modules, add their folder with @source so Tailwind picks up the classes they use, and style their state with data-* or aria-* variants.
Conclusion
Tailwind scales well in React when you treat components as the unit of reuse. Put your tokens in @theme, use semantic names so dark mode is a variable swap, merge classes with a cn() helper that puts className last, and define variants with cva instead of string concatenation. Lean on aria-*, data-*, group, peer, and container query variants so CSS handles state instead of extra React logic.
If your project already has class soup, start with three changes: add cn() with tailwind-merge, install the Prettier plugin, and refactor your most-used component (usually a button) to cva. Then search for template strings inside className and replace each one with a lookup map. Those small fixes remove most of the pain teams associate with Tailwind.


