How to buildOctober 11, 202611 min read
Markup and layout: HTML and CSSStep 5 of 7

Tailwind CSS: utilities, responsiveness and theming

In short

Tailwind CSS is a set of small utility classes (p-6, flex, text-lg) that you combine right in your markup. The build step scans project files and generates CSS only for the classes that actually appear. In version 4 configuration lives in CSS: @import "tailwindcss" adds the framework, @theme defines colors, fonts and breakpoints as variables, and the md:, hover: and dark: prefixes apply a style only at a given screen width, in a given state or in dark mode.

TutorialLevel: BeginnerTailwind CSSPractice: ~45–90 min

After this you will be able to

  • add Tailwind CSS 4 to a Vite project and explain why the final CSS contains only the classes you use
  • build a responsive card grid from mobile to desktop with the md: and lg: breakpoints
  • style hover:, focus-visible:, disabled: and group-hover: states and test them with the keyboard
  • move colors and a font into @theme and enable dark mode via dark: with a toggle that remembers the choice

Verified with

Tailwind CSS 4.3Vite 8.3Node.js 24 LTS (24.18)Next.js 16.4

Checked against docs: October 11, 2026

Contents
  1. 01The utility-first approach: benefits and costs
  2. 02Installing Tailwind CSS v4 (Vite, PostCSS, Next.js)
  3. 03Core utilities: spacing, typography, color, flex and grid
  4. 04Responsive design: the sm, md and lg breakpoints
  5. 05States: hover, focus-visible, disabled, group
  6. 06Theming: @theme, CSS variables and dark mode
  7. 07When to extract a component instead of using @apply
  8. 08Checks and exercises

The utility-first approach: benefits and costs

In classic CSS you invent a name (.pricing-card) and then describe it in a separate file. Tailwind has almost no names: an element gets a set of single-purpose classes. rounded-2xl rounds the corners, p-6 sets padding, bg-white sets the background. Each class does one thing, and values come from a shared scale: spacing in multiples of 0.25rem, colors from a palette, font sizes from a type scale.

What you get

  • No class names to invent and no switching between HTML and CSS
  • Changing a class affects only that element, so other pages do not break
  • Scale values keep the design consistent, unlike magic numbers in style
  • States and responsiveness use the same classes, which inline styles cannot do
  • CSS stops growing with every new page because classes are reused

What it costs

  • Long class lists in markup take getting used to
  • Repeated elements must become components, or edits have to happen in several places
  • Class names cannot be assembled from pieces at runtime
  • The team needs a shared utility vocabulary and a single theme

Why the CSS stays small: Tailwind is not a ready-made stylesheet but a generator. At build time it scans project files, finds everything that looks like a class name and generates CSS only for what it found. In our project the final CSS is 14.18 kB (3.68 kB gzipped), and it has no bg-red-500, for example, because that class appears nowhere.

Installing Tailwind CSS v4 (Vite, PostCSS, Next.js)

The practice project is a pricing section for a fictional Kite CRM service: three cards that stack on a phone, sit two per row on a tablet and three in a row on desktop, plus a dark theme with a toggle. The project uses Vite with the vanilla template and no framework, so you can see exactly what Tailwind does. Installation follows the official guide:

Terminal
npm create vite@latest pricing -- --template vanilla
cd pricing
npm install
npm install tailwindcss @tailwindcss/vite
A new Vite project and the Tailwind packages; the template sample files (counter.js and styles) can be deleted
vite.config.js
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [tailwindcss()],
});
The Tailwind plugin for Vite

Everything else is configured in CSS. The first line, @import "tailwindcss", adds the framework layers: the theme with default variables, base styles (Preflight) and utilities. There is no more tailwind.config.js and no @tailwind base directives from version 3. Here is the project's final style.css, which we will go through in the theming section:

src/style.css
@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

@theme {
  --font-display: Georgia, "Times New Roman", serif;
  --color-brand-500: oklch(0.62 0.17 255);
  --color-brand-600: oklch(0.54 0.19 260);
  --color-brand-700: oklch(0.47 0.18 262);
}
All of the project's CSS: the import, a class-based dark variant and theme tokens

Vite

Package
@tailwindcss/vite
Setup
A plugin in vite.config

PostCSS (webpack and others)

Package
@tailwindcss/postcss + postcss
Setup
A plugin in postcss.config.mjs

Next.js

Package
@tailwindcss/postcss per the docs
Setup
create-next-app 16.4 sets Tailwind up itself; app/globals.css uses the same @import "tailwindcss"

We checked that the same theme works in Next.js 16.4: the lesson's style.css went into app/globals.css of a create-next-app project, and the build generated the same classes. That project wires Tailwind through the @tailwindcss/turbopack loader in next.config.ts, and Next.js additionally duplicated the oklch colors with hex fallbacks.

Core utilities: spacing, typography, color, flex and grid

Here is the whole page. The script in <head> picks the theme before the first paint; we will come back to it in the dark mode section. The rest is plain HTML with classes:

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Kite CRM — Pricing</title>
    <script>
      document.documentElement.classList.toggle(
        "dark",
        localStorage.theme === "dark" ||
          (!("theme" in localStorage) &&
            window.matchMedia("(prefers-color-scheme: dark)").matches),
      );
    </script>
    <script type="module" src="/src/main.js"></script>
  </head>
  <body class="bg-slate-50 text-slate-900 antialiased dark:bg-slate-950 dark:text-slate-100">
    <main class="mx-auto max-w-6xl px-4 py-12 sm:px-6 lg:py-20">
      <header class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
        <div>
          <h1 class="font-display text-3xl font-bold sm:text-4xl">Pricing</h1>
          <p class="mt-2 text-slate-600 dark:text-slate-400">
            Pick a plan for your team. Switch any time.
          </p>
        </div>
        <button
          id="theme-toggle"
          type="button"
          class="self-start rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium hover:bg-slate-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600 sm:self-auto dark:border-slate-700 dark:hover:bg-slate-800"
        >
          Dark theme
        </button>
      </header>

      <ul class="mt-10 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        <li class="flex flex-col rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
          <h2 class="text-lg font-semibold">Free</h2>
          <p class="mt-4 flex items-baseline gap-1">
            <span class="text-4xl font-bold">$0</span>
            <span class="text-sm text-slate-500 dark:text-slate-400">/ month</span>
          </p>
          <ul class="mt-6 mb-8 space-y-2 text-sm text-slate-600 dark:text-slate-300">
            <li>1 user</li>
            <li>100 leads</li>
          </ul>
          <button
            type="button"
            disabled
            class="mt-auto rounded-lg border border-slate-300 px-4 py-2.5 font-semibold disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700"
          >
            Current plan
          </button>
        </li>

        <li class="group relative flex flex-col rounded-2xl bg-white p-6 shadow-lg ring-2 ring-brand-600 transition hover:-translate-y-1 hover:shadow-xl dark:bg-slate-900">
          <span class="absolute -top-3 left-6 rounded-full bg-brand-600 px-3 py-1 text-xs font-semibold text-white">
            Popular
          </span>
          <h2 class="text-lg font-semibold group-hover:text-brand-600 dark:group-hover:text-brand-500">Team</h2>
          <p class="mt-4 flex items-baseline gap-1">
            <span class="text-4xl font-bold">$12</span>
            <span class="text-sm text-slate-500 dark:text-slate-400">/ user / month</span>
          </p>
          <ul class="mt-6 mb-8 space-y-2 text-sm text-slate-600 dark:text-slate-300">
            <li>Up to 20 users</li>
            <li>Unlimited leads</li>
            <li>Telegram notifications</li>
          </ul>
          <a
            href="#signup"
            class="mt-auto rounded-lg bg-brand-600 px-4 py-2.5 text-center font-semibold text-white hover:bg-brand-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600"
          >
            Start free trial
          </a>
        </li>

        <li class="group flex flex-col rounded-2xl border border-slate-200 bg-white p-6 transition hover:-translate-y-1 hover:shadow-lg md:col-span-2 lg:col-span-1 dark:border-slate-800 dark:bg-slate-900">
          <h2 class="text-lg font-semibold group-hover:text-brand-600 dark:group-hover:text-brand-500">Business</h2>
          <p class="mt-4 flex items-baseline gap-1">
            <span class="text-4xl font-bold">$29</span>
            <span class="text-sm text-slate-500 dark:text-slate-400">/ user / month</span>
          </p>
          <ul class="mt-6 mb-8 space-y-2 text-sm text-slate-600 dark:text-slate-300">
            <li>Unlimited users</li>
            <li>Roles and audit log</li>
            <li>1C integration</li>
          </ul>
          <a
            href="#contact"
            class="mt-auto rounded-lg border border-slate-300 px-4 py-2.5 text-center font-semibold hover:border-brand-600 hover:text-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600 dark:border-slate-700"
          >
            Contact sales
          </a>
        </li>
      </ul>
    </main>
  </body>
</html>
The full pricing page

Spacing

Utilities in the example
p-6, px-4, mt-10, mx-auto
CSS
padding, margin; one scale step is 0.25rem, p-6 = 1.5rem

Width

Utilities in the example
max-w-6xl
CSS
max-width from the container scale

Typography

Utilities in the example
text-4xl, font-bold, text-sm
CSS
font-size with a matching line-height, font-weight

Color

Utilities in the example
bg-white, text-slate-600, border-slate-200
CSS
background-color, color, border-color from the palette

Flex

Utilities in the example
flex flex-col, items-baseline, gap-1
CSS
display: flex, direction, alignment, gaps

Grid

Utilities in the example
grid gap-6, md:grid-cols-2
CSS
display: grid, columns repeat(2, minmax(0, 1fr))

Radius and shadow

Utilities in the example
rounded-2xl, shadow-lg, ring-2
CSS
border-radius, box-shadow

Note mt-auto on the buttons: the card is a flex column, and an automatic top margin pushes the button to the bottom. That keeps the buttons in all cards on one line even though the lists have different lengths. It is the same flexbox from the layout lesson, written as classes.

Responsive design: the sm, md and lg breakpoints

Any utility can be enabled only from a certain screen width: put a breakpoint prefix and a colon before the class. Tailwind is mobile-first: an unprefixed class applies everywhere, while md:grid-cols-2 applies from 48rem up. The docs warn about a common mistake: sm: does not mean "on small screens". Write the mobile version without prefixes, and use prefixes to add changes for wider screens.

sm

Minimum width
40rem (640px)
Media query
@media (width >= 40rem)

md

Minimum width
48rem (768px)
Media query
@media (width >= 48rem)

lg

Minimum width
64rem (1024px)
Media query
@media (width >= 64rem)

xl

Minimum width
80rem (1280px)
Media query
@media (width >= 80rem)

2xl

Minimum width
96rem (1536px)
Media query
@media (width >= 96rem)
  1. Phone: grid gap-6

    Without prefixes the grid has one column and the cards stack. The header is flex flex-col: title and theme button in a column.

  2. From 640px: sm:flex-row

    The header becomes a row and the theme button moves to the right (sm:justify-between, sm:self-auto).

  3. From 768px: md:grid-cols-2

    Two columns. The third card spans the full width with md:col-span-2 so it does not sit alone in half a row.

  4. From 1024px: lg:grid-cols-3

    Three columns, and lg:col-span-1 gives the third card its normal width back. Vertical page padding grows with lg:py-20.

Checked in headless Edge after the build: at 375px the grid has one column, at 800px two, at 1280px three, and there is no horizontal scrolling at any width. For ranges there are max-* variants: md:max-lg:flex applies only between 768 and 1024px. And if a component should adapt to its parent's width rather than the screen, use container queries: the @container class on the parent and @md: prefixes on the children.

States: hover, focus-visible, disabled, group

States are written like breakpoints: a variant prefix before the utility. hover:bg-brand-700 does nothing until the pointer is over the element. Variants stack: dark:hover:bg-slate-800 is a hover background, but only in dark mode.

hover:

Where in the example
hover:bg-brand-700, hover:-translate-y-1
When it applies
Mouse hover; in v4 only on devices that support hover

focus-visible:

Where in the example
focus-visible:outline-2 focus-visible:outline-brand-600
When it applies
Keyboard focus; no outline on mouse click

disabled:

Where in the example
disabled:opacity-60 disabled:cursor-not-allowed
When it applies
The button has the disabled attribute

group + group-hover:

Where in the example
group on the card, group-hover:text-brand-600 on the title
When it applies
Hovering the parent changes a child's style

An important version 4 detail: hover: is wrapped in @media (hover: hover). On a phone a tap no longer gets stuck in the hover state, but you also cannot rely on hover: the docs advise treating it as an enhancement, not the only way to reveal something. Here is the generated CSS, fragments of a real unminified build:

dist/assets/index.css
@layer theme {
  :root, :host {
    /* … the default theme variables that are in use … */
    --font-display: Georgia, "Times New Roman", serif;
    --color-brand-500: oklch(62% .17 255);
    --color-brand-600: oklch(54% .19 260);
    --color-brand-700: oklch(47% .18 262);
  }
}

@layer utilities {
  /* … */
  .bg-brand-600 {
    background-color: var(--color-brand-600);
  }

  @media (min-width: 48rem) {
    /* … */
    .md\:grid-cols-2 {
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }
  }

  @media (hover: hover) {
    /* … */
    .hover\:bg-brand-700:hover {
      background-color: var(--color-brand-700);
    }
  }

  .dark\:bg-slate-900:where(.dark, .dark *) {
    background-color: var(--color-slate-900);
  }
}
Fragments of real CSS after vite build --minify false. In this build the width >= 48rem condition is written as the equivalent min-width: 48rem; the minified build keeps width>=48rem

The keyboard check: the first Tab lands on the theme button, the second skips the disabled "Current plan" button and lands on "Start free trial", which gets a solid 2px outline. The disabled button computes to opacity: 0.6 and cursor: not-allowed, and hovering the Team card turns its title the brand color.

Theming: @theme, CSS variables and dark mode

Design tokens such as brand colors, fonts and breakpoints are defined in CSS with the @theme directive in Tailwind 4. These are not plain CSS variables: every variable in a known namespace creates utilities. --color-brand-600 gives you bg-brand-600, text-brand-600, ring-brand-600 and every other color utility; --font-display gives you font-display. Tailwind also outputs them as regular variables on :root, so the value is available in your own CSS via var(--color-brand-600).

--color-*

What it creates
All color utilities
Example
--color-brand-600 → bg-brand-600

--font-*

What it creates
Font family
Example
--font-display → font-display

--breakpoint-*

What it creates
Responsive variants
Example
--breakpoint-3xl: 120rem → 3xl:

--spacing

What it creates
The spacing and sizing scale
Example
--spacing: 0.25rem → p-6 = 1.5rem

--radius-*, --shadow-*

What it creates
Radii and shadows
Example
--radius-xl → rounded-xl

New variables extend the default theme, redefined ones replace a value (for example --breakpoint-sm: 30rem), and --color-*: initial removes the whole default palette. Use @theme for tokens that need utilities and :root for variables that do not.

By default dark: follows the system setting via prefers-color-scheme. We also want a toggle on the page, so the line @custom-variant dark (&:where(.dark, .dark *)); in style.css overrides the variant: dark: now applies when <html> has the dark class. The script in <head> sets that class before the first paint, from the saved choice or, if there is none, from the system theme. That way the page does not flash light before turning dark. The button toggles the class and remembers the choice:

src/main.js
import "./style.css";

const toggle = document.querySelector("#theme-toggle");
toggle.setAttribute(
  "aria-pressed",
  String(document.documentElement.classList.contains("dark")),
);

toggle.addEventListener("click", () => {
  const isDark = document.documentElement.classList.toggle("dark");
  localStorage.theme = isDark ? "dark" : "light";
  toggle.setAttribute("aria-pressed", String(isDark));
});
Theme toggle: a class on html, the choice in localStorage, the state in aria-pressed

Verified in headless Edge: with a dark system theme and empty localStorage, <html> has the dark class right away; clicking the button in light mode switches the page background from slate-50 to slate-950, writes localStorage.theme = "dark" and sets aria-pressed="true", and the dark theme survives a reload.

When to extract a component instead of using @apply

The "Start free trial" and "Contact sales" buttons share many classes. The Tailwind docs call a component the best answer to repeated styles: a React or Vue component, a template partial in Blade or Twig, or a loop in the markup. Styles and structure then live in one place, and an edit happens once.

A custom CSS class makes sense for a single simple element when a component feels like overkill, or when you have to style markup you do not control: a third-party widget, HTML from a CMS. Such classes go into the components layer, and @apply lets you build them from the same utilities and tokens:

src/style.css
@layer components {
  .btn {
    @apply rounded-lg px-4 py-2.5 text-center font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600;
  }
}
Fragment: a component class built from utilities; variants such as focus-visible work inside @apply too
index.html
<a href="#signup" class="btn mt-auto rounded-none bg-brand-600 text-white hover:bg-brand-700">
  Start free trial
</a>
Fragment: the rounded-none utility overrides the radius from .btn

We checked it in a browser: the link kept the 10px 16px padding and font weight 600 from .btn, while the radius became 0px because the rounded-none utility won. The reason is layer order: Tailwind declares theme, base, components, utilities, so utilities always beat component classes regardless of the order of classes in the attribute.

A block of several elements repeats (a card, a table row)

Solution
A component or a template partial

It repeats in a list

Solution
A loop in the markup, so the classes are written once

A single simple element in a template engine without components

Solution
A class in @layer components, optionally with @apply

Third-party code generates the markup (a widget, Markdown from a CMS)

Solution
Your own CSS with var(--color-…) or @apply

Two classes fight over one property (grid and flex)

Solution
Keep one and choose it with a condition in code

Checks and exercises

Terminal
$ npm run build

vite v8.3.4 building client environment for production...
transforming...
✓ 5 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                  4.74 kB │ gzip: 1.37 kB
dist/assets/index-0MkxrnFc.css  14.18 kB │ gzip: 3.68 kB
dist/assets/index-DApDsg-q.js    0.97 kB │ gzip: 0.51 kB

✓ built in 91ms
Real build output: CSS from the classes you use only
  • The mobile version is written without prefixes, and sm:/md:/lg: only add changes
  • No width produces horizontal scrolling
  • Every interactive element shows keyboard focus (focus-visible:)
  • Hover is an enhancement: without it everything is still clear and reachable
  • Brand colors and fonts live in @theme instead of repeated arbitrary values like bg-[#2067da]
  • Every light color has a dark-mode counterpart, and the toggle remembers the choice
  • Class names in code are complete, never assembled from pieces
  • Repeated blocks are extracted into components or loops
How this was verified6 · passed1 · not runchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.