Topic 11.3
Tailwind CSS with React
In one line
Tailwind is a CSS framework of small single-purpose classes (px-4, bg-teal-600, hover:bg-teal-700) that you combine in className; a build step generates only the classes you use, and a cn() helper merges conditional and conflicting classes.
Think of it like this
A paint-by-numbers kit versus a box of LEGO. A ready-made stylesheet class (.card) is a finished painting: great until you want it a little different. Tailwind is a box of tiny bricks, each doing one thing: one sets padding, one sets a colour, one rounds corners. You build each element's look from bricks right in the markup, and because the bricks never change, nothing you build somewhere else can break what you built here.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Utility class
- A class that does one small thing, like
p-4(padding) ortext-white(text colour). - Tailwind CSS
- A CSS framework made of utility classes, with a build step that generates only the classes you use.
- Variant
- A prefix like
hover:ormd:that applies a utility only in one condition. - Design scale
- A fixed set of allowed sizes and colours (spacing 1, 2, 4…, palette shades) so the UI stays consistent.
- tailwind-merge
- A library that removes conflicting Tailwind classes, keeping the last one (
px-4 px-6→px-6). - cn()
- A small helper,
twMerge(clsx(...)), that builds conditional class lists and resolves conflicts. - @apply
- A Tailwind directive that copies utilities into your own CSS class. Best used rarely.
- @theme
- A Tailwind v4 CSS block where you define design tokens (colours, fonts, spacing) that become utilities.
- cva
- class-variance-authority: a helper that maps props like
variantandsizeto class lists.
Step by step
01Install Tailwind in a Vite + React project
Two packages: Tailwind itself and its Vite plugin. The plugin runs Tailwind on your CSS during development and the build.
$ npm install tailwindcss @tailwindcss/vite02Add the plugin and import Tailwind
Add tailwindcss() next to the React plugin, then replace the contents of src/index.css with one import. @theme adds your own tokens on top of the defaults.
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
});
/* src/index.css */
@import "tailwindcss";
@theme {
--color-brand-500: #0b7285;
--font-display: "Inter", sans-serif;
}03Style with utilities, state with variants
Put the look in className and the state in attributes. aria-pressed is good for screen readers and gives Tailwind something to style with aria-pressed:.
function TagButton({ label, on, onToggle }) {
return (
<button
aria-pressed={on}
onClick={onToggle}
className="rounded-full border border-slate-300 px-3 py-1 text-sm hover:bg-slate-100
focus-visible:outline-2 focus-visible:outline-teal-600
aria-pressed:bg-teal-600 aria-pressed:text-white"
>
{label}
</button>
);
}04Write cn() once
Install clsx and tailwind-merge, then put the helper in a shared file. shadcn/ui and most templates use exactly this file (Topic 11.5).
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}05Extract a component with variants
Repeated class lists become a component. cva turns variant and size props into classes; cn lets callers add or override classes safely.
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
const button = cva("inline-flex items-center rounded-md font-medium disabled:opacity-50", {
variants: {
variant: { primary: "bg-teal-600 text-white hover:bg-teal-700", ghost: "hover:bg-slate-100" },
size: { sm: "px-3 py-1 text-sm", md: "px-4 py-2" },
},
defaultVariants: { variant: "primary", size: "md" },
});
export function Button({ variant, size, className, ...props }) {
return <button className={cn(button({ variant, size }), className)} {...props} />;
}Try it yourself
- 1
Add a disabled state with a variant
In the tag example, give the Testing button
disabledand adddisabled:opacity-50to the class list, plus the hand-written rule.disabled\:opacity-50:disabled { opacity: 0.5; }in the CSS tab. Predict whether clicking it still toggles. (It doesn't: disabled buttons fire no clicks.) - 2
Teach the mini merge a new group
Call
cn("rounded-md", "rounded-full")in the finalsetTimeout. Then callcn("font-medium", "font-bold"). The first is merged and the second isn't; add afont-weightgroup togroupOfand run again. - 3
Swap the stylesheet order
Move
.bg-rose-600below.bg-teal-600in the second example's CSS tab. The naive button turns rose. Predict what happens if Tailwind's ordering changes in an upgrade: that fragility is whycnmatters.
Code & diagrams
React only sets `aria-pressed`; the `aria-pressed:` variant does the styling. Look at the CSS tab: a variant is just a class whose name contains a colon (escaped as `\:` in CSS) plus a condition. Click tags and press Tab to see the focus outline.
Preview
Console
Nothing logged yet.Expected console output
React true rgb(13, 148, 136)
CSS false rgb(255, 255, 255)
Design false rgb(255, 255, 255)
Testing false rgb(255, 255, 255)The naive button has both `bg-teal-600` and `bg-rose-600`; the rule that comes later in the stylesheet wins, so the override is ignored and the button stays teal. `cn` removed the base `bg-teal-600` and `px-4`, so the caller's classes apply. The last line shows that `text-sm` (a size) and `text-rose-600` (a colour) don't conflict.
Preview
Console
Nothing logged yet.Expected console output
Naive: rounded-md bg-teal-600 px-4 py-2 font-medium text-white bg-rose-600 px-6
background: rgb(13, 148, 136)
With cn: rounded-md py-2 font-medium text-white bg-rose-600 px-6
background: rgb(225, 29, 72)
text-sm text-rose-600@layer theme {
:root { --spacing: 0.25rem; --color-teal-600: oklch(60% 0.118 184.704); /* ... */ }
}
@layer utilities {
.bg-teal-600 {
background-color: var(--color-teal-600);
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
@media (hover: hover) {
.hover\:bg-teal-700:hover {
background-color: var(--color-teal-700);
}
}
.disabled\:opacity-50:disabled {
opacity: 50%;
}
.aria-pressed\:bg-teal-600[aria-pressed="true"] {
background-color: var(--color-teal-600);
}
.group-data-\[state\=open\]\:block:is(:where(.group)[data-state="open"] *) {
display: block;
}
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Build a class name from pieces
In a real Tailwind project, build the class with a template literal that joins bg-, a color variable and -600, with color set to "rose".
Break #2
Override a component's class without merging
In the cn example, the NaiveButton receives className="bg-rose-600 px-6".
Myth vs fact
Myth
Tailwind is the same as inline styles.
Fact
Utilities support hover, focus, media and container queries, dark mode and attribute variants, use a shared design scale, and compile to a small cached stylesheet. Inline styles can do none of that.
Myth
Tailwind makes huge CSS files.
Fact
Only classes found in your source are generated, and each utility is written once however often you use it, so the CSS stops growing once the design vocabulary is in place.
Myth
The order of classes in className decides which one wins.
Fact
The order of the rules in the stylesheet decides. That is exactly why tailwind-merge exists.
Myth
You should move long class lists into @apply classes.
Fact
Extract a React component instead. The component is the reusable unit; @apply brings back a separate stylesheet to maintain.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Tailwind v4 (released January 2025) moved configuration into CSS (
@theme,@custom-variant,@source,@utility), emits cascade layers (theme,base,components,utilities) and exposes every token as a CSS variable, sovar(--color-teal-600)works in your own CSS and instyle. - ▸
Dark mode via
dark:followsprefers-color-schemeby default. For a manual toggle, redefine the variant in CSS:@custom-variant dark (&:where(.dark, .dark *));and put.darkon<html>. - ▸
tailwind-merge has to understand Tailwind's class grammar (including arbitrary values like
p-[3px]and your@themeadditions), so it is several kilobytes; libraries that only join classes useclsx. If you add custom utilities, configure it withextendTailwindMerge. - ▸
prettier-plugin-tailwindcsssorts classes into Tailwind's own order, which makes reviews easier and conflicts easier to spot. With Server Components, Tailwind needs no client JavaScript, which is a large part of why it became the default in the Next.js and shadcn/ui ecosystem.
Remember this
- 1
A utility class is a class that does exactly one thing:
p-4sets padding,text-smsets font size,rounded-lgrounds corners,flexmakes a flex container. Tailwind CSS is a large, consistent set of them with a built-in design scale (spacing steps, a colour palette, font sizes). In React you write them inclassName:<button className="rounded-md bg-teal-600 px-4 py-2 text-white">. You rarely write a CSS file; the styles live next to the markup they affect, so deleting a component deletes its styles too. - 2
Tailwind is a build tool, not a stylesheet you download. It scans your source files as plain text, finds every class name it recognises, and generates CSS for those only. A production app usually ships a small stylesheet. The catch: the scanner reads text, it doesn't run your code, so a class must appear complete in the source.
bg-${color}-600produces nothing; write a lookup such as{ teal: "bg-teal-600", rose: "bg-rose-600" }[color]. - 3
Variants are prefixes that apply a utility only in some condition:
hover:bg-teal-700(on hover),focus-visible:ring-2(keyboard focus),disabled:opacity-50,md:grid-cols-3(screens at least 48rem wide),dark:bg-slate-900(dark mode),aria-pressed:bg-teal-600(whenaria-pressed="true"),data-[state=open]:block(a data attribute),group-hover:underline(when a parent with classgroupis hovered). In React, state becomes attributes and Tailwind styles the attributes: setaria-pressed={on}ordata-state={open ? "open" : "closed"}and let variants do the rest, which also keeps the markup accessible. - 4
Conditional classes work like in Topic 11.1: build the string with
clsx. The new problem is conflicts. If aButtonhasbg-teal-600and a parent passesclassName="bg-rose-600", the element gets both. Which one wins is decided by the order of the rules in Tailwind's generated CSS, not by the order in yourclassName, so overrides silently fail. tailwind-merge (twMerge) knows which utilities conflict and keeps only the last one. Nearly every Tailwind + React codebase combines them in a helper called **cn**:const cn = (...inputs) => twMerge(clsx(inputs));. - 5
**Reusing styles: extract components, not
@apply.** When the same 10 classes appear on every button, the Tailwind way is aButtoncomponent that owns those classes and exposes props such asvariantandsize(often with the class-variance-authority library,cva, which maps variant props to class lists).@applycopies utilities into a custom CSS class (.btn { @apply px-4 py-2; }); it works, but you lose what made Tailwind useful (styles visible in the markup) and end up maintaining a stylesheet again. Use it sparingly, for things you can't render as a component, like styling third-party HTML. - 6
Setup with Vite (Tailwind v4): install
tailwindcssand@tailwindcss/vite, add the plugin tovite.config.js, and put@import "tailwindcss";in your CSS. There is no requiredtailwind.config.jsany more: v4 detects your source files automatically and you customise the design scale in CSS with@theme(for example--color-brand-500: #0b7285;createsbg-brand-500,text-brand-500and friends). Next.js uses the@tailwindcss/postcssplugin instead of the Vite one. In the Hectal preview Tailwind can't run, so the live examples below define the few utility classes they use by hand in the CSS tab (simplified values); the JSX is exactly what you would write with real Tailwind.
Explain it without notes
How does Tailwind decide which CSS to generate, and what does that mean for building class names dynamically?
What are variants? Show how React state can drive Tailwind styles through attributes.
Why do Tailwind projects use a cn() helper? What do clsx and tailwind-merge each do?
Why is extracting a component preferred over @apply?
Practice
Write an Alert component with a tone prop (info, success, danger) that uses complete Tailwind class names from a lookup object, and accepts a className that can override them.
Make a disclosure (a button that shows and hides a panel) where the panel's visibility is styled with a data-state attribute and Tailwind's data-[state=open]: variant.
Trade-offs
- ↔
Tailwind removes naming, collisions and dead CSS and works perfectly with Server Components, but long class lists are noisy and the team must learn the vocabulary. Good components and the Prettier plugin keep it readable.
- ↔
Utilities make one-off designs fast; a strict design scale can feel limiting for unusual layouts. Arbitrary values (
w-[37rem]) exist but used too often they erode the consistency Tailwind provides. - ↔
tailwind-mergeadds a few kilobytes and some runtime work per render; for components that never accept override classes, plainclsxis enough.
Done when you can
I can style a React component with Tailwind utilities and variants.
I can drive styles from state through
aria-*anddata-*attributes.I can explain why dynamic class names like
bg-${color}-600fail.I can write and use a
cn()helper and explain tailwind-merge.I can extract a component with variants instead of using
@apply.I can set up Tailwind v4 in a Vite project.