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.
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:andlg:breakpoints - style
hover:,focus-visible:,disabled:andgroup-hover:states and test them with the keyboard - move colors and a font into
@themeand enable dark mode viadark:with a toggle that remembers the choice
Know beforehand
Verified with
Tailwind CSS 4.3Vite 8.3Node.js 24 LTS (24.18)Next.js 16.4
Checked against docs: October 11, 2026
Contents
- The utility-first approach: benefits and costs
- Installing Tailwind CSS v4 (Vite, PostCSS, Next.js)
- Core utilities: spacing, typography, color, flex and grid
- Responsive design: the sm, md and lg breakpoints
- States: hover, focus-visible, disabled, group
- Theming: @theme, CSS variables and dark mode
- When to extract a component instead of using @apply
- Checks 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:
npm create vite@latest pricing -- --template vanilla
cd pricing
npm install
npm install tailwindcss @tailwindcss/viteimport { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()],
});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:
@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);
}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/postcssper the docs- Setup
create-next-app16.4 sets Tailwind up itself;app/globals.cssuses 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:
<!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>Spacing
- Utilities in the example
p-6,px-4,mt-10,mx-auto- CSS
padding,margin; one scale step is0.25rem,p-6=1.5rem
Width
- Utilities in the example
max-w-6xl- CSS
max-widthfrom the container scale
Typography
- Utilities in the example
text-4xl,font-bold,text-sm- CSS
font-sizewith a matchingline-height,font-weight
Color
- Utilities in the example
bg-white,text-slate-600,border-slate-200- CSS
background-color,color,border-colorfrom 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, columnsrepeat(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)
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.From 640px: sm:flex-row
The header becomes a row and the theme button moves to the right (
sm:justify-between,sm:self-auto).From 768px: md:grid-cols-2
Two columns. The third card spans the full width with
md:col-span-2so it does not sit alone in half a row.From 1024px: lg:grid-cols-3
Three columns, and
lg:col-span-1gives the third card its normal width back. Vertical page padding grows withlg: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
disabledattribute
group + group-hover:
- Where in the example
groupon the card,group-hover:text-brand-600on 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:
@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);
}
}vite build --minify false. In this build the width >= 48rem condition is written as the equivalent min-width: 48rem; the minified build keeps width>=48remThe 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:
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));
});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:
@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;
}
}<a href="#signup" class="btn mt-auto rounded-none bg-brand-600 text-white hover:bg-brand-700">
Start free trial
</a>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
$ 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- 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
@themeinstead of repeated arbitrary values likebg-[#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.