Styling React with Tailwind CSS
Reviewed & published by Brayan K
Tailwind CSS is a utility-first framework: instead of writing CSS in separate files, you compose small single-purpose classes directly in your JSX className. flex, p-4, text-lg, hover:bg-blue-600 — each does one thing. You build whole interfaces without leaving your component, with built-in responsive design, dark mode, and a consistent design scale. This whole site is styled with Tailwind. By the end you'll read and write utility classes fluently and compose them conditionally in React.
Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Apply utility classes for layout, spacing, color, and type
- Use state variants like hover:, focus:, and disabled:
- Build responsive layouts with sm: / md: / lg: prefixes
- Toggle classes conditionally with clsx / cn in JSX
- Extract a reusable styled component instead of repeating classes
- Add dark-mode styles with the dark: variant
1️⃣ Utility-First Basics
Each class maps to a tiny slice of CSS. Layout (flex, grid), spacing (p-4 = padding, gap-2), color (bg-blue-500, text-white), and so on. Here's a card built entirely from utilities:
function ProfileCard({ name, role }) {
return (
<div className="flex items-center gap-4 p-6 rounded-xl bg-white shadow-md">
<div className="h-12 w-12 rounded-full bg-blue-500" />
<div>
<h3 className="text-lg font-semibold text-slate-900">{name}</h3>
<p className="text-sm text-slate-500">{role}</p>
</div>
</div>
);
}Read it like a sentence: a flex row, items centered, gap 4, padding 6, extra-large rounded corners, white background, medium shadow. The demo models how variant classes switch on and off with state:
// Tailwind variants (hover:, md:, dark:) are conditional classes. We can't
// run real CSS here, so we model the RESOLVER: given the current UI state,
// which utility classes are actually active right now?
function activeClasses(base, variants, state) {
// base classes always apply; a variant applies only if its condition is on
const active = [...base];
for (const [condition, classes] of Object.entries(variants)) {
if (state[condition]) active.push(...classes);
}
return active.join(" ");
}
const button = {
base: ["px-4", "py-2", "rounded", "bg-blue-500", "text-white"],
variants: {
hover: ["bg-blue-600"], // hover:bg-blue-600
dark: ["bg-blue-400"], // dark:bg-blue-400
disabled: ["opacity-50", "cursor-not-allowed"],
},
};
console.log(activeClasses(button.base, button.variants, { hover: false }));
// px-4 py-2 rounded bg-blue-500 text-white
console.log(activeClasses(button.base, button.variants, { hover: true }));
// px-4 py-2 rounded bg-blue-500 text-white bg-blue-600
console.log(activeClasses(button.base, button.variants, { disabled: true }));
// px-4 py-2 rounded bg-blue-500 text-white opacity-50 cursor-not-allowed2️⃣ Variants: hover, focus, dark
Prefix a class with a variant to apply it conditionally. This is Tailwind's superpower over inline styles — you can express interaction states and themes right in the markup.
<button
className="
px-4 py-2 rounded font-medium
bg-blue-500 text-white
hover:bg-blue-600 /* on hover */
focus:outline-none focus:ring-2 focus:ring-blue-400
disabled:opacity-50 disabled:cursor-not-allowed
dark:bg-blue-400 dark:hover:bg-blue-300 /* in dark mode */
"
>
Save
</button>3️⃣ Conditional Classes in React
In React you constantly toggle styles based on state — active tab, error field, selected item. The clean pattern is a helper like clsx (many projects wrap it as cn(), which also merges conflicting Tailwind classes):
import clsx from "clsx";
function Tab({ label, isActive }) {
return (
<button
className={clsx(
"px-3 py-2 text-sm font-medium border-b-2",
isActive
? "border-blue-500 text-blue-600" // active styles
: "border-transparent text-slate-500 hover:text-slate-800"
)}
>
{label}
</button>
);
}Build your own tiny clsx to see exactly how falsy values get dropped. Fill in the blanks:
// 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".
// Build a mini clsx(): join truthy class names, skip falsy ones. This is
// exactly how you toggle Tailwind classes conditionally in JSX.
function clsx(...args) {
return args
// 1) keep only the truthy entries (drop false, null, undefined, "")
.filter((c) => ___) // 👉 Boolean
.join(" ");
}
const isActive = true;
const isDisabled = false;
const cls = clsx(
"px-4 py-2 rounded",
isActive && "bg-blue-500 text-white",
// 2) only add the disabled classes when isDisabled is true
___ && "opacity-50 cursor-not-allowed" // 👉 isDisabled
);
console.log(cls);
// ✅ Expected output:
// px-4 py-2 rounded bg-blue-500 text-white4️⃣ Responsive Design
Tailwind is mobile-first: unprefixed classes apply at all sizes, and a prefix like md: applies from that breakpoint up. So you style the small screen first, then layer overrides for bigger screens.
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{products.map((p) => <ProductCard key={p.id} {...p} />)}
</div>
<nav className="flex flex-col md:flex-row md:items-center gap-2 md:gap-6">
...
</nav>The demo models how the "largest matching breakpoint wins" so you can see how many columns render at each screen width:
// Responsive prefixes are "min-width" rules: md: kicks in at >= 768px and
// stays on for larger screens. We model picking the class for a given width.
const breakpoints = { base: 0, sm: 640, md: 768, lg: 1024, xl: 1280 };
// e.g. grid-cols: { base: 1, md: 2, lg: 4 } -> how many columns at width?
function resolve(map, width) {
let chosen = map.base;
for (const [bp, value] of Object.entries(map)) {
if (bp !== "base" && width >= breakpoints[bp]) chosen = value;
}
return chosen;
}
// className="grid-cols-1 md:grid-cols-2 lg:grid-cols-4"
const cols = { base: 1, md: 2, lg: 4 };
console.log("phone (375px):", resolve(cols, 375)); // 1
console.log("tablet (800px):", resolve(cols, 800)); // 2
console.log("laptop (1280px):", resolve(cols, 1280)); // 4
// Largest matching breakpoint wins — exactly how Tailwind's min-width works.5️⃣ Don't Repeat Classes — Extract a Component
If you find the same long class string in five places, that's a signal: extract a component, not a CSS class. The React way to "reuse styles" is to reuse the component that owns them.
Reorder Challenge
These lines of a responsive card grid are scrambled. Put them in the correct order:
A. {items.map((i) => <Card key={i.id} {...i} />)}
B. <section className="p-6">
C. <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
D. </div>
E. </section>Quick Recall
1) What does md:flex mean?
Apply display: flex from the md breakpoint (≥768px) upward. Below that, the element isn't flex. Tailwind is mobile-first, so prefixes mean "this size and bigger."
2) Why is hover:bg-blue-600 impossible with inline style=?
Inline styles can't express pseudo-states like :hover, media queries, or dark mode. Variants compile to real CSS rules, which is exactly what inline styles lack.
3) You're repeating the same 8 classes in 5 components. What's the fix?
Extract a component (e.g. <Button>) that owns those classes. In React you reuse the component, not a CSS class. @apply is a last resort.
Common Errors (and the fix)
- Building class names dynamically: `bg-${color}-500` won't be generated — the scanner can't see it. Use full static strings or a lookup map.
- Using class instead of className: this is JSX, not HTML. It's always className.
- Conflicting utilities: p-2 p-4 — the later one wins by source order, which is brittle. Use tailwind-merge / cn() to resolve conflicts predictably.
- Dark mode does nothing: the dark: variant needs dark mode enabled (a dark class on a parent, or the media strategy).
📋 Quick Reference
| Goal | Classes |
|---|---|
| Flex row, centered | flex items-center justify-center |
| Padding / margin | p-4 px-6 py-2 mt-4 |
| Text | text-lg font-semibold text-slate-700 |
| Hover state | hover:bg-blue-600 |
| Responsive | grid-cols-1 md:grid-cols-3 |
| Dark mode | dark:bg-slate-900 dark:text-slate-100 |
Mini-Challenge: Badge Classes by Status
A status badge picks its color classes from its status. Finish badgeClasses so each status returns the right utility string — the engine behind every colored pill in a dashboard.
// Map a status to its badge classes (base + color).
function badgeClasses(status) {
const base = "px-2 py-1 rounded-full text-xs font-medium";
const colors = {
success: "bg-green-100 text-green-800",
warning: "bg-amber-100 text-amber-800",
error: "bg-red-100 text-red-800",
};
// your code here: combine base with the color for 'status'
// (fall back to a neutral gray if status is unknown)
return base + " " + (colors[status] || "bg-gray-100 text-gray-800");
}
console.log(badgeClasses("success"));
console.log(badgeClasses("error"));
console.log(badgeClasses("unknown"));🎉 Lesson Complete
- ✅ Utility-first: compose tiny classes in className
- ✅ Variants (hover:, focus:, dark:) express states inline
- ✅ Mobile-first responsive prefixes: md: means "this size and up"
- ✅ Toggle classes with clsx/cn; write full static strings
- ✅ Repeating classes? Extract a component, not a CSS rule
Practice quiz
What is Tailwind CSS's core approach?
- A component library of pre-built widgets
- A CSS-in-JS runtime
- Utility-first: compose small single-purpose classes directly in className
- A preprocessor like Sass
Answer: Utility-first: compose small single-purpose classes directly in className. Tailwind is utility-first. You build interfaces by snapping together small classes (flex, p-4, text-lg) right in your JSX.
What does the prefix in hover:bg-blue-600 do?
- Applies bg-blue-600 only on hover
- Applies bg-blue-600 always
- Disables the background on hover
- Applies it only in dark mode
Answer: Applies bg-blue-600 only on hover. hover: is a variant modifier. It gates the utility so bg-blue-600 applies only when the element is hovered.
In Tailwind's mobile-first model, what does md:flex mean?
- Apply flex only on screens smaller than md
- Apply flex only at exactly 768px
- Apply flex on hover
- Apply flex from the md breakpoint (>=768px) and up
Answer: Apply flex from the md breakpoint (>=768px) and up. Tailwind is mobile-first: a prefix like md: means 'this size and larger.' Unprefixed classes apply at all sizes.
How do variant stacks like md:hover:underline read?
- Underline always, ignoring md and hover
- On hover, at the md breakpoint and above
- Only on the md breakpoint, never on hover
- Underline on focus only
Answer: On hover, at the md breakpoint and above. Read right-to-left: the utility is underline, gated by hover, gated by md. Variants stack to express responsive, stateful styling.
Why won't a class built dynamically like `bg-${color}-500` work?
- The scanner only generates CSS for class names it can see as complete static strings
- Tailwind doesn't support blue
- Template literals are banned in JSX
- It works fine
Answer: The scanner only generates CSS for class names it can see as complete static strings. Tailwind scans source for literal class names and generates only those. A runtime-built string is invisible to the scanner, so use full static strings or a lookup map.
How does Tailwind keep the CSS bundle small despite thousands of utilities?
- It compresses CSS with gzip only
- It ships every utility and lets the browser cache them
- It scans your files and generates CSS only for the classes you actually use
- It inlines all styles
Answer: It scans your files and generates CSS only for the classes you actually use. Unused utilities never reach the bundle. Tailwind generates CSS for exactly the class names it finds in your source.
You're repeating the same 8 classes across 5 components. What's the idiomatic React fix?
- Copy and paste them everywhere
- Extract a component (e.g. a <Button>) that owns those classes
- Always reach for @apply first
- Switch to inline styles
Answer: Extract a component (e.g. a <Button>) that owns those classes. In React you reuse the component that owns the styles, not a CSS class. @apply is a last resort for truly repeated patterns.
Why is hover:bg-blue-600 impossible with an inline style= attribute?
- Inline styles are slower
- React blocks inline hover
- It actually works with inline styles
- Inline styles can't express pseudo-states, media queries, or dark mode
Answer: Inline styles can't express pseudo-states, media queries, or dark mode. Variants compile to real CSS rules. Inline styles apply to one element and can't represent :hover, breakpoints, or dark mode.
What does a helper like clsx (often wrapped as cn) do for class names?
- Validates class names against the Tailwind config
- Composes class strings cleanly, dropping falsy values, and cn additionally merges conflicting utilities
- Minifies the final CSS
- Converts classes to inline styles
Answer: Composes class strings cleanly, dropping falsy values, and cn additionally merges conflicting utilities. clsx joins truthy class names and skips falsy ones. Many projects' cn() also uses tailwind-merge to resolve conflicting utilities predictably.
Your dark: variant does nothing. What's the likely cause?
- dark: was removed from Tailwind
- You need to use inline styles
- Dark mode isn't enabled (no dark class on a parent, or the media strategy isn't set)
- The class name is misspelled as darkmode:
Answer: Dark mode isn't enabled (no dark class on a parent, or the media strategy isn't set). The dark: variant needs dark mode active — either a dark class on a parent element (class strategy) or the media strategy configured.
Continue this course
- Previous: Testing with React Testing Library + Vitest
- Next: Next.js & the App Router — The leading React framework — file-based routing and server rendering
- Quick reference: React cheat sheet