Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~25 min· topic 3 of 6

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) or text-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: or md: 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 variant and size to 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.

Terminalwhole filebash
$ npm install tailwindcss @tailwindcss/vite

02Add 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 + src/index.csswhole filejavascript
// 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:.

TagButton.jsxwhole filejsx
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).

src/lib/utils.jswhole filejavascript
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.

Button.jsxwhole filejsx
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} />;
}
Extract a component with variantsdiagram
Rendering diagram…

Try it yourself

  1. 1

    Add a disabled state with a variant

    In the tag example, give the Testing button disabled and add disabled:opacity-50 to 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. 2

    Teach the mini merge a new group

    Call cn("rounded-md", "rounded-full") in the final setTimeout. Then call cn("font-medium", "font-bold"). The first is merged and the second isn't; add a font-weight group to groupOf and run again.

  3. 3

    Swap the stylesheet order

    Move .bg-rose-600 below .bg-teal-600 in 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 why cn matters.

Code & diagrams

Utilities and variants: state as attributes New tab

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

Press Run to see the page.

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)
Why cn() exists: conflicting classes and tailwind-merge New tab

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

Press Run to see the page.

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
What real Tailwind v4 generates for some of these classes (compiled with tailwindcss 4.3)css
@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".

terminal
$ Browser
── what you'll see ──
The element has class="bg-rose-600" but no background colour. No error, no warning.

Break #2

Override a component's class without merging

In the cn example, the NaiveButton receives className="bg-rose-600 px-6".

terminal
$ Console
── what you'll see ──
Naive: rounded-md bg-teal-600 px-4 py-2 font-medium text-white bg-rose-600 px-6
background: rgb(13, 148, 136)

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, so var(--color-teal-600) works in your own CSS and in style.

  • ▸

    Dark mode via dark: follows prefers-color-scheme by default. For a manual toggle, redefine the variant in CSS: @custom-variant dark (&:where(.dark, .dark *)); and put .dark on <html>.

  • ▸

    tailwind-merge has to understand Tailwind's class grammar (including arbitrary values like p-[3px] and your @theme additions), so it is several kilobytes; libraries that only join classes use clsx. If you add custom utilities, configure it with extendTailwindMerge.

  • ▸

    prettier-plugin-tailwindcss sorts 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. 1

    A utility class is a class that does exactly one thing: p-4 sets padding, text-sm sets font size, rounded-lg rounds corners, flex makes 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 in className: <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. 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}-600 produces nothing; write a lookup such as { teal: "bg-teal-600", rose: "bg-rose-600" }[color].

  3. 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 (when aria-pressed="true"), data-[state=open]:block (a data attribute), group-hover:underline (when a parent with class group is hovered). In React, state becomes attributes and Tailwind styles the attributes: set aria-pressed={on} or data-state={open ? "open" : "closed"} and let variants do the rest, which also keeps the markup accessible.

  4. 4

    Conditional classes work like in Topic 11.1: build the string with clsx. The new problem is conflicts. If a Button has bg-teal-600 and a parent passes className="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 your className, 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. 5

    **Reusing styles: extract components, not @apply.** When the same 10 classes appear on every button, the Tailwind way is a Button component that owns those classes and exposes props such as variant and size (often with the class-variance-authority library, cva, which maps variant props to class lists). @apply copies 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. 6

    Setup with Vite (Tailwind v4): install tailwindcss and @tailwindcss/vite, add the plugin to vite.config.js, and put @import "tailwindcss"; in your CSS. There is no required tailwind.config.js any more: v4 detects your source files automatically and you customise the design scale in CSS with @theme (for example --color-brand-500: #0b7285; creates bg-brand-500, text-brand-500 and friends). Next.js uses the @tailwindcss/postcss plugin 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

01

How does Tailwind decide which CSS to generate, and what does that mean for building class names dynamically?

02

What are variants? Show how React state can drive Tailwind styles through attributes.

03

Why do Tailwind projects use a cn() helper? What do clsx and tailwind-merge each do?

04

Why is extracting a component preferred over @apply?

Practice

01

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.

02

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-merge adds a few kilobytes and some runtime work per render; for components that never accept override classes, plain clsx is enough.

Done when you can

  • I can style a React component with Tailwind utilities and variants.

  • I can drive styles from state through aria-* and data-* attributes.

  • I can explain why dynamic class names like bg-${color}-600 fail.

  • 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.