# Tailwind CSS

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

Frontend Developer roadmap · TypeScript & React · 18 min read · https://levelcode.org/roadmaps/frontend/tailwind-css

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.

| Class | CSS | Size |
| --- | --- | --- |
| `p-1` | `padding: calc(var(--spacing) * 1)` | 4px |
| `px-4` | `padding-inline: calc(var(--spacing) * 4)` | 16px |
| `mt-8` | `margin-top: calc(var(--spacing) * 8)` | 32px |
| `size-10` | `width` and `height` | 40px |
| `w-full` / `w-1/2` | `width: 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.

> [!NOTE]
> In v4 a plain `border` is `1px solid currentColor`, so it takes the text colour. Add a colour explicitly, for example `border border-gray-200`, when you want a subtle divider.

## 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*.

| Prefix | Applies 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;
}
```

| Token | Utilities you get |
| --- | --- |
| `--color-brand-600` | `bg-brand-600`, `text-brand-600`, `border-brand-600`, ... |
| `--font-display` | `font-display` |
| `--radius-card` | `rounded-card` |
| `--breakpoint-3xl` | the `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.

> [!TIP]
> If a class has no effect, open DevTools and look for it on the element. If the class is there but no rule matches, Tailwind did not generate it: a typo, a dynamic name, or a file outside the scanned sources.

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

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

## Quiz

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

   A. Template literals are not allowed in className
   B. Tailwind scans source text and never sees the full class names text-sm or text-lg
   C. The text utilities need a md: prefix to work
   D. Preflight overrides every font-size utility

   **Answer: B.** Tailwind generates CSS for class names that appear literally in your files. It does not run your code, so a name built at runtime is never generated. Map each option to a complete class string instead.

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

   A. 6px
   B. 0.6rem
   C. 6rem
   D. 1.5rem

   **Answer: D.** Spacing utilities multiply `--spacing`, which defaults to 0.25rem. 6 × 0.25rem = 1.5rem, or 24px at the default font size.

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?

   A. Column on the phone, row on the laptop
   B. Row on the phone, column on the laptop
   C. Row on both
   D. Column on both

   **Answer: A.** Breakpoints are mobile-first. The unprefixed `flex-col` applies at every width, and `md:flex-row` overrides it from 48rem (768px) up.

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

   A. In a `colors` key in tailwind.config.js
   B. As a `.bg-brand-600` rule written with @apply
   C. As `--color-brand-600` inside an `@theme` block in your CSS
   D. In the `css` section of vite.config.ts

   **Answer: C.** v4 is configured in CSS. A variable in the `--color-*` namespace inside `@theme` generates every colour utility for it: bg-, text-, border-, outline- and so on.
