Step 13 of 21 · branch option

Tailwind CSS

Style React components with Tailwind v4 utilities, variants and @theme tokens, and keep class lists readable as the codebase grows.

One of 2 options for Style your components — finish any one to complete the step.Switch to CSS Modules
18 min 9 sections

Once your UI is made of components, the hard part of CSS changes. It is no longer "how do I centre this", but "which of these 400 class names is still used, and what breaks if I edit it?" Tailwind CSS answers that by moving styling into the markup: you compose small, single-purpose classes directly in your JSX, so a component's styles live and die with the component. This lesson covers Tailwind v4 with Vite and React: setup, the design scales, variants, theming with @theme, and the habits that keep it maintainable. (The alternative branch, CSS Modules, keeps styles in scoped .css files instead; both reach the same result.)

Utility-first thinking

In traditional CSS you name a thing, then describe it in a stylesheet:

css
.badge {
  display: inline-flex;
  padding: 0.25rem 0.5rem;
  border-radius: 9999px;
  font-size: 0.75rem;
  background: #dcfce7;
  color: #166534;
}

With Tailwind, each of those declarations already exists as a utility class, and you apply them directly:

tsx
export function Badge({ label }: { label: string }) {
  return (
    <span className="inline-flex rounded-full bg-green-100 px-2 py-1 text-xs text-green-800">
      {label}
    </span>
  )
}

That looks noisy at first, and the objection "isn't this just inline styles?" is fair to raise. It is not, for three reasons:

  • Constraints. px-2 is not any value; it is a step on a shared spacing scale. Teams stop inventing 13px paddings.
  • Variants. Inline styles cannot express hover, focus, media queries or dark mode. Utilities can: hover:bg-green-200, md:px-4.
  • One stylesheet that stops growing. Tailwind generates CSS only for the classes you actually use, and every component reuses the same ones. Deleting a component deletes its styles, because they were never anywhere else.

The component, not the class name, becomes your unit of reuse. You write <Badge> in fifty places; the utility list lives once, inside it.

Setting up Tailwind v4 with Vite

Start from a Vite React + TypeScript project and install Tailwind and its Vite plugin:

bash
npm create vite@latest shop -- --template react-ts
cd shop
npm install tailwindcss @tailwindcss/vite

Add the plugin next to the React one:

ts
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [react(), tailwindcss()],
})

Replace the contents of src/index.css with a single line, and make sure src/main.tsx imports it (the template already does with import './index.css'):

css
/* src/index.css */
@import "tailwindcss";

That is the whole setup. There is no tailwind.config.js and no list of content paths: v4 scans your project's source files automatically (skipping anything in .gitignore, such as node_modules) and generates a rule for every class name it finds. If you need classes from a package in node_modules, add it explicitly with @source "../node_modules/@acme/ui"; below the import.

The import also brings in Preflight, a small reset. It removes default margins, makes headings and lists unstyled, and sets box-sizing: border-box everywhere. Your <h1> will look like plain text until you give it utilities; that is intended.

The design scales

Most utilities map onto a scale defined by theme variables.

Spacing. One variable, --spacing: 0.25rem, drives padding, margin, gap, width, height and inset. A number means "that many steps": p-4 is calc(var(--spacing) * 4), which is 1rem, and gap-6 is 1.5rem. Any integer works, so mt-13 is valid even though no one listed it.

ClassCSSSize
p-1padding: calc(var(--spacing) * 1)4px
px-4padding-inline: calc(var(--spacing) * 4)16px
mt-8margin-top: calc(var(--spacing) * 8)32px
size-10width and height40px
w-full / w-1/2width: 100% / 50%relative

Colour. Each palette (gray, red, blue, green, sky and so on) has shades from 50 (lightest) to 950 (darkest), defined in OKLCH. Use them with any colour utility: bg-sky-600, text-gray-700, border-red-300, outline-blue-500. A slash adds opacity: bg-black/50 is half-transparent black.

Type. text-sm, text-base, text-lg up to text-9xl set font size with a matching line height; font-medium, font-semibold and font-bold set weight.

Variants: responsive, state and dark mode

A variant is a prefix that applies a utility only under a condition. You can stack them, and they read left to right.

Responsive

Breakpoints are mobile-first: an unprefixed utility applies at every width, and md: applies from that width up.

PrefixApplies from
sm:40rem (640px)
md:48rem (768px)
lg:64rem (1024px)
xl:80rem (1280px)
tsx
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">{/* ... */}</ul>

One column on phones, two from 640px, three from 1024px. To target only small screens, use max-md: (below 768px).

State

tsx
<button
  type="button"
  className="rounded-lg bg-sky-600 px-4 py-2 text-white hover:bg-sky-700 active:bg-sky-800
             focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600
             disabled:opacity-50"
>
  Save
</button>

hover:, active:, focus-visible: and disabled: map to the matching pseudo-classes. focus-visible: shows the ring for keyboard users without flashing it on every mouse click. Stacking narrows the condition: enabled:hover:bg-sky-700 only darkens a button that is not disabled.

To style a child when its parent is hovered, mark the parent with group and use group-hover: on the child:

tsx
<a href="/lamp" className="group block">
  <h3 className="group-hover:underline">Desk lamp</h3>
</a>

Dark mode

dark: applies when the operating system is in dark mode (prefers-color-scheme: dark):

tsx
<div className="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">...</div>

If your app has its own theme toggle instead, redefine the variant to follow a .dark class on <html>:

css
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

CSS-first configuration with @theme

You customise Tailwind in CSS, not JavaScript. Variables declared inside @theme become design tokens: each one generates utilities based on its namespace.

css
/* src/index.css */
@import "tailwindcss";

@theme {
  --color-brand-500: oklch(0.62 0.19 262);
  --color-brand-600: oklch(0.55 0.2 262);
  --color-brand-700: oklch(0.48 0.19 262);
  --font-display: "Bricolage Grotesque", system-ui, sans-serif;
  --radius-card: 1.25rem;
  --breakpoint-3xl: 120rem;
}
TokenUtilities you get
--color-brand-600bg-brand-600, text-brand-600, border-brand-600, ...
--font-displayfont-display
--radius-cardrounded-card
--breakpoint-3xlthe 3xl: variant

These extend the defaults. To replace a whole namespace, reset it first: --color-*: initial; removes every built-in colour so only yours remain. Tokens are also real CSS variables, so var(--color-brand-600) works in any hand-written CSS or inline style.

Components, conditional classes and arbitrary values

Extract React components, not CSS classes

When the same utility list appears twice, the fix is a component. Here is a button with variants, using clsx (npm install clsx) to join class strings conditionally:

tsx
// src/Button.tsx
import clsx from 'clsx'
import type { ComponentProps } from 'react'

const variants = {
  primary: 'bg-brand-600 text-white enabled:hover:bg-brand-700',
  secondary: 'border border-gray-300 bg-white text-gray-900 enabled:hover:bg-gray-50',
} as const

type ButtonProps = ComponentProps<'button'> & { variant?: keyof typeof variants }

export function Button({ variant = 'primary', type = 'button', className, ...props }: ButtonProps) {
  return (
    <button
      type={type}
      className={clsx(
        'rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
        variants[variant],
        className,
      )}
      {...props}
    />
  )
}

clsx accepts strings, falsy values (which it drops) and objects: clsx('px-4', isActive && 'bg-brand-600', { 'opacity-50': loading }). Because ButtonProps extends the native button props, callers still get onClick, disabled and every other attribute with full types.

Tailwind also has @apply, which copies utilities into a regular CSS rule. Reach for it rarely, for HTML you do not render yourself (such as Markdown output). Using it to rebuild .btn and .card classes brings back the naming and dead-CSS problems Tailwind removed, and splits each component across two files.

Arbitrary values

When a design needs a one-off value, square brackets generate it on the spot:

tsx
<div className="grid grid-cols-[10rem_1fr] gap-4 lg:max-w-[65ch]">...</div>
<div className="bg-(--promo-bg) [mask-type:luminance]">...</div>

Underscores stand for spaces. bg-(--promo-bg) is shorthand for bg-[var(--promo-bg)], and [property:value] sets any CSS property. Treat brackets as an escape hatch: if a value repeats, make it a token.

Pitfalls

Dynamic class names are not generated. Tailwind reads your source files as plain text; it never runs your code. It sees bg-red-600 written out in full, but not a string assembled at runtime:

tsx
// Wrong: neither "bg-red-600" nor "bg-green-600" appears in the source
<span className={`bg-${tone}-600`} />

// Right: complete class names, chosen at runtime
const toneClass = { danger: 'bg-red-600', success: 'bg-green-600' } as const
<span className={toneClass[tone]} />

The broken version renders without errors and simply has no background. Whenever a class "doesn't work", first check it appears literally somewhere.

Conflicting classes do not obey attribute order. <Button className="p-2"> produces px-4 py-2 ... p-2, yet the button keeps its 16px horizontal padding. Which utility wins depends on the order of rules in the generated stylesheet (Tailwind emits p-2 before px-4), not the order in class. If components accept overrides, run the result through tailwind-merge (twMerge(clsx(...))), which drops the earlier conflicting utility.

Readability. Long class lists are the price of Tailwind. Keep them manageable: extract components early, group classes by concern (layout, spacing, colour, states), and add prettier-plugin-tailwindcss so classes are sorted consistently on save. Tailwind's editor extension shows the generated CSS on hover, which helps when reading unfamiliar classes.

Practice

Style a ProductCard for a shop. Set up Tailwind in a Vite react-ts project as above, then build:

ts
export type Product = { id: string; name: string; priceCents: number; imageUrl: string; stock: number }
  1. A card with an image, the product name, a formatted price and an "Add to cart" button that calls onAddToCart(product.id).
  2. Responsive: below 640px the image sits on top at full width; from 640px it is a 10rem-wide square beside the text.
  3. Hover and focus: the card's shadow grows on hover; the button darkens on hover and shows a 2px outline with an offset on keyboard focus.
  4. Sold out: when stock is 0 the image turns greyscale, and the button is disabled, reads "Sold out", looks muted and does not react to hover.
  5. Dark mode: follows the OS setting, including the page background.
  6. Render two cards from App, one of them sold out.
Show solution
css
/* src/index.css */
@import "tailwindcss";

@theme {
  --color-brand-500: oklch(0.62 0.19 262);
  --color-brand-600: oklch(0.55 0.2 262);
  --color-brand-700: oklch(0.48 0.19 262);
}
tsx
// src/ProductCard.tsx
import clsx from 'clsx'

export type Product = { id: string; name: string; priceCents: number; imageUrl: string; stock: number }

const price = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })

type ProductCardProps = { product: Product; onAddToCart: (id: string) => void }

export function ProductCard({ product, onAddToCart }: ProductCardProps) {
  const soldOut = product.stock === 0

  return (
    <article className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm transition-shadow hover:shadow-lg sm:flex-row dark:border-gray-800 dark:bg-gray-900">
      {/* The name is right below, so the image is decorative */}
      <img
        src={product.imageUrl}
        alt=""
        className={clsx(
          'aspect-4/3 w-full shrink-0 object-cover sm:aspect-square sm:w-40',
          soldOut && 'opacity-70 grayscale',
        )}
      />
      <div className="flex flex-1 flex-col gap-2 p-4">
        <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-50">{product.name}</h3>
        <p className="font-bold text-gray-700 tabular-nums dark:text-gray-300">
          {price.format(product.priceCents / 100)}
        </p>
        <button
          type="button"
          disabled={soldOut}
          onClick={() => onAddToCart(product.id)}
          className={clsx(
            'mt-auto rounded-lg px-4 py-2 font-medium transition-colors',
            'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600 dark:focus-visible:outline-brand-500',
            soldOut
              ? 'cursor-not-allowed bg-gray-200 text-gray-500 dark:bg-gray-800 dark:text-gray-400'
              : 'cursor-pointer bg-brand-600 text-white hover:bg-brand-700',
          )}
        >
          {soldOut ? 'Sold out' : 'Add to cart'}
        </button>
      </div>
    </article>
  )
}
tsx
// src/App.tsx
import { ProductCard, type Product } from './ProductCard'

const products: Product[] = [
  { id: 'p1', name: 'Desk lamp', priceCents: 3900, imageUrl: 'https://picsum.photos/seed/lamp/480', stock: 4 },
  { id: 'p2', name: 'Notebook', priceCents: 650, imageUrl: 'https://picsum.photos/seed/notebook/480', stock: 0 },
]

export function App() {
  return (
    <div className="min-h-dvh bg-gray-50 dark:bg-gray-950">
      <main className="mx-auto grid max-w-2xl gap-4 p-4 sm:p-8">
        {products.map((product) => (
          <ProductCard
            key={product.id}
            product={product}
            onAddToCart={(id) => console.log('add', id)}
          />
        ))}
      </main>
    </div>
  )
}

Things to notice: every class is written out in full, so Tailwind generates all of them, and clsx adds or picks between complete strings. The sold-out branch has no hover: class at all, so it cannot react to hover. sm:flex-row and sm:w-40 switch the layout at 640px while the unprefixed classes handle phones. shrink-0 stops flexbox squeezing the image when the title is long. Every colour has a dark: partner, including the page background on the wrapper.

Key takeaways

  • Tailwind styles components with single-purpose utility classes; the React component, not a CSS class, is the unit of reuse.
  • v4 setup is the @tailwindcss/vite plugin plus @import "tailwindcss"; source files are detected automatically.
  • Utilities sit on shared scales: --spacing steps of 0.25rem and colour shades from 50 to 950.
  • Variants are mobile-first and stackable: md:, hover:, focus-visible:, disabled:, dark:, enabled:hover:.
  • Customise with @theme tokens in CSS; each variable namespace generates matching utilities.
  • Always write complete class names, use clsx for conditions, and treat arbitrary values and @apply as escape hatches.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.A component uses className={`text-${size}`} where size is "sm" or "lg". Both sizes render as the default text size. Why?

  2. 2.What does `p-6` compute to with Tailwind v4's default theme?

  3. 3.An element has `className="flex-col md:flex-row"`. How is it laid out on a 500px-wide phone and on a 1000px-wide laptop?

  4. 4.In Tailwind v4, where do you add a brand colour so that `bg-brand-600` works?

Finished reading?

Mark it complete to fill in your roadmap.