Tailwind CSS — набор маленьких классов-утилит (p-6, flex, text-lg), из которых стиль собирается прямо в разметке. Сборщик просматривает файлы проекта и генерирует CSS только для классов, которые реально встречаются. В версии 4 настройка живёт в CSS: @import "tailwindcss" подключает фреймворк, @theme задаёт цвета, шрифты и брейкпоинты как переменные, а префиксы md:, hover: и dark: включают стиль только на нужной ширине экрана, в нужном состоянии или в тёмной теме.
После материала вы сможете
- подключить Tailwind CSS 4 к проекту на Vite и объяснить, почему в итоговом CSS только использованные классы
- сверстать адаптивную сетку карточек от мобильной версии к десктопу с брейкпоинтами
md:иlg: - оформить состояния
hover:,focus-visible:,disabled:иgroup-hover:и проверить их с клавиатуры - вынести цвета и шрифт в
@themeи включить тёмный режим черезdark:с переключателем и сохранением выбора
Что знать заранее
Проверено на
Tailwind CSS 4.3Vite 8.3Node.js 24 LTS (24.18)Next.js 16.4
Сверено с документацией: 11 октября 2026 г.
Содержание
- Утилитарный подход: плюсы и цена
- Установка Tailwind CSS v4 (Vite, PostCSS, Next.js)
- Базовые утилиты: отступы, типографика, цвета, flex и grid
- Адаптивность: брейкпоинты sm, md, lg
- Состояния: hover, focus-visible, disabled, group
- Тема: @theme, CSS-переменные и тёмный режим
- Когда выносить в компонент, а не в @apply
- Проверка и упражнения
Утилитарный подход: плюсы и цена
В классическом CSS вы придумываете имя (.pricing-card), а потом описываете его в отдельном файле. В Tailwind имён почти нет: элемент получает набор однозначных классов — rounded-2xl скругляет углы, p-6 задаёт внутренний отступ, bg-white — фон. Каждый класс делает одну вещь, а значения берутся из общей шкалы: отступы кратны 0.25rem, цвета — из палитры, размеры шрифта — из типографской шкалы.
Что даёт
- Не нужно придумывать имена классов и переключаться между HTML и CSS
- Изменение класса затрагивает только этот элемент — соседние страницы не ломаются
- Значения из шкалы держат дизайн последовательным, в отличие от «магических чисел» в
style - Состояния и адаптивность пишутся теми же классами — inline-стили так не умеют
- CSS не растёт с каждой новой страницей: классы переиспользуются
Чего стоит
- Длинные строки классов в разметке — к ним нужно привыкнуть
- Повторяющиеся элементы нужно выносить в компоненты, иначе правки придётся делать в нескольких местах
- Имена классов нельзя собирать из кусочков в рантайме
- Команде нужен общий словарь утилит и единая тема
Почему CSS не раздувается: Tailwind — не готовая таблица стилей, а генератор. При сборке он просматривает файлы проекта, ищет всё, что похоже на имя класса, и генерирует CSS только для найденного. В нашем проекте итоговый CSS весит 14,18 КБ (3,68 КБ в gzip), и в нём нет, например, bg-red-500 — этот класс нигде не встречается.
Установка Tailwind CSS v4 (Vite, PostCSS, Next.js)
Практика урока — блок тарифов вымышленного сервиса Kite CRM: три карточки, которые на телефоне идут столбиком, на планшете — по две, на десктопе — в ряд, плюс тёмная тема с переключателем. Проект — Vite с шаблоном vanilla, без фреймворка: так видно, что делает именно Tailwind. Установка по официальной инструкции:
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()],
});Вся остальная настройка — в CSS. Первая строка @import "tailwindcss" подключает слои фреймворка: тему с переменными по умолчанию, базовые стили (Preflight) и утилиты. Файла tailwind.config.js и директив @tailwind base из третьей версии больше нет. Итоговый 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);
}Vite
- Пакет
@tailwindcss/vite- Подключение
- Плагин в
vite.config
PostCSS (webpack и другие)
- Пакет
@tailwindcss/postcss+postcss- Подключение
- Плагин в
postcss.config.mjs
Next.js
- Пакет
@tailwindcss/postcssпо документации- Подключение
create-next-app16.4 подключает Tailwind сам; вapp/globals.cssтот же@import "tailwindcss"
Мы проверили, что та же тема работает и в Next.js 16.4: style.css урока подставили в app/globals.css проекта из create-next-app, и сборка сгенерировала те же классы. В этом проекте Tailwind подключён через загрузчик @tailwindcss/turbopack в next.config.ts, а цвета oklch Next.js дополнительно продублировал запасными hex-значениями.
Базовые утилиты: отступы, типографика, цвета, flex и grid
Вся разметка страницы. Скрипт в <head> выбирает тему до отрисовки — вернёмся к нему в разделе о тёмном режиме. Остальное — обычный 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>Отступы
- Утилиты из примера
p-6,px-4,mt-10,mx-auto- CSS
padding,margin; шаг шкалы —0.25rem,p-6=1.5rem
Ширина
- Утилиты из примера
max-w-6xl- CSS
max-widthиз шкалы контейнеров
Типографика
- Утилиты из примера
text-4xl,font-bold,text-sm- CSS
font-sizeс подобраннойline-height,font-weight
Цвета
- Утилиты из примера
bg-white,text-slate-600,border-slate-200- CSS
background-color,color,border-colorиз палитры
Flex
- Утилиты из примера
flex flex-col,items-baseline,gap-1- CSS
display: flex, направление, выравнивание, промежутки
Grid
- Утилиты из примера
grid gap-6,md:grid-cols-2- CSS
display: grid, колонкиrepeat(2, minmax(0, 1fr))
Скругление и тень
- Утилиты из примера
rounded-2xl,shadow-lg,ring-2- CSS
border-radius,box-shadow
Обратите внимание на mt-auto у кнопок: карточка — flex-колонка, и автоматический отступ сверху прижимает кнопку к низу. Поэтому кнопки во всех карточках стоят на одной линии, хотя пунктов в списках разное количество. Это тот же flexbox из урока о раскладке, только записанный классами.
Адаптивность: брейкпоинты sm, md, lg
Любую утилиту можно включить только начиная с определённой ширины экрана — для этого перед классом ставят префикс брейкпоинта и двоеточие. Tailwind работает по принципу mobile-first: класс без префикса действует везде, а md:grid-cols-2 — от 48rem и шире. Документация предупреждает о частой ошибке: sm: не означает «на маленьких экранах». Мобильную версию пишут без префиксов, а префиксами добавляют изменения для более широких экранов.
sm
- Минимальная ширина
- 40rem (640px)
- Media query
@media (width >= 40rem)
md
- Минимальная ширина
- 48rem (768px)
- Media query
@media (width >= 48rem)
lg
- Минимальная ширина
- 64rem (1024px)
- Media query
@media (width >= 64rem)
xl
- Минимальная ширина
- 80rem (1280px)
- Media query
@media (width >= 80rem)
2xl
- Минимальная ширина
- 96rem (1536px)
- Media query
@media (width >= 96rem)
Телефон: grid gap-6
Без префиксов сетка из одной колонки — карточки идут друг под другом. Шапка —
flex flex-col: заголовок и кнопка темы в столбик.От 640px: sm:flex-row
Шапка становится строкой, кнопка темы уходит вправо (
sm:justify-between,sm:self-auto).От 768px: md:grid-cols-2
Две колонки. Третья карточка растягивается на всю ширину через
md:col-span-2, чтобы не висеть одна в половине ряда.От 1024px: lg:grid-cols-3
Три колонки, и
lg:col-span-1возвращает третьей карточке обычную ширину. Вертикальные поля страницы увеличиваются черезlg:py-20.
Проверка в headless Edge после сборки: при ширине 375px сетка из одной колонки, при 800px — из двух, при 1280px — из трёх, и ни на одной ширине нет горизонтальной прокрутки. Для диапазонов есть варианты max-*: md:max-lg:flex действует только между 768 и 1024px. А если компонент должен подстраиваться под ширину родителя, а не экрана, используют контейнерные запросы: класс @container у родителя и префиксы @md: у детей.
Состояния: hover, focus-visible, disabled, group
Состояния записываются так же, как брейкпоинты: префикс-вариант перед утилитой. hover:bg-brand-700 ничего не делает, пока курсор не наведён на элемент. Варианты можно комбинировать: dark:hover:bg-slate-800 — фон при наведении, но только в тёмной теме.
hover:
- Где в примере
hover:bg-brand-700,hover:-translate-y-1- Когда срабатывает
- Наведение мыши; в v4 — только на устройствах, которые поддерживают hover
focus-visible:
- Где в примере
focus-visible:outline-2 focus-visible:outline-brand-600- Когда срабатывает
- Фокус с клавиатуры; при клике мышью рамка не появляется
disabled:
- Где в примере
disabled:opacity-60 disabled:cursor-not-allowed- Когда срабатывает
- У кнопки есть атрибут
disabled
group + group-hover:
- Где в примере
groupна карточке,group-hover:text-brand-600на заголовке- Когда срабатывает
- Наведение на родителя меняет стиль потомка
Важная деталь четвёртой версии: hover: оборачивается в @media (hover: hover). На телефоне касание больше не «залипает» в состоянии наведения, но и полагаться на hover нельзя: документация советует считать его улучшением, а не единственным способом что-то показать. Вот как выглядит сгенерированный 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);
}
}vite build --minify false. В этой сборке условие width >= 48rem записано эквивалентным min-width: 48rem; в минифицированной осталось width>=48remПроверка с клавиатуры: первый Tab попадает на кнопку темы, второй — пропускает заблокированную кнопку «Current plan» и попадает на «Start free trial», вокруг которой появляется сплошная рамка 2px. У заблокированной кнопки вычисленные opacity: 0.6 и cursor: not-allowed, а наведение на карточку Team перекрашивает её заголовок в цвет бренда.
Тема: @theme, CSS-переменные и тёмный режим
Дизайн-токены — цвета бренда, шрифты, брейкпоинты — в Tailwind 4 задаются в CSS директивой @theme. Это не обычные CSS-переменные: каждая переменная из известного пространства имён создаёт утилиты. --color-brand-600 даёт классы bg-brand-600, text-brand-600, ring-brand-600 и все остальные цветовые; --font-display — класс font-display. Одновременно Tailwind выводит их как обычные переменные в :root, поэтому значение доступно и в своём CSS через var(--color-brand-600).
--color-*
- Что создаёт
- Все цветовые утилиты
- Пример
--color-brand-600→bg-brand-600
--font-*
- Что создаёт
- Семейство шрифта
- Пример
--font-display→font-display
--breakpoint-*
- Что создаёт
- Адаптивные варианты
- Пример
--breakpoint-3xl: 120rem→3xl:
--spacing
- Что создаёт
- Шкала отступов и размеров
- Пример
--spacing: 0.25rem→p-6=1.5rem
--radius-*, --shadow-*
- Что создаёт
- Скругления и тени
- Пример
--radius-xl→rounded-xl
Новые переменные расширяют стандартную тему, переопределённые — заменяют значение (например, --breakpoint-sm: 30rem), а --color-*: initial убирает всю стандартную палитру. Используйте @theme для токенов, которым нужны утилиты, и :root — для переменных, которым утилиты не нужны.
По умолчанию dark: срабатывает по системной настройке через prefers-color-scheme. Нам нужен ещё и переключатель на странице, поэтому строка @custom-variant dark (&:where(.dark, .dark *)); в style.css переопределяет вариант: теперь dark: действует, когда у <html> есть класс dark. Скрипт в <head> ставит этот класс до отрисовки — по сохранённому выбору или, если выбора нет, по системной теме. Так страница не мигает светлой темой перед тёмной. Кнопка переключает класс и запоминает выбор:
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));
});Проверено в headless Edge: при системной тёмной теме и пустом localStorage у <html> сразу есть класс dark; клик по кнопке в светлой теме меняет фон страницы со slate-50 на slate-950, записывает localStorage.theme = "dark" и ставит aria-pressed="true", а после перезагрузки тёмная тема сохраняется.
Когда выносить в компонент, а не в @apply
Классы кнопок «Start free trial» и «Contact sales» во многом совпадают. Документация Tailwind называет лучшим решением для повторяющихся стилей компонент: React- или Vue-компонент, шаблон-partial в Blade или Twig, цикл в разметке. Стили и структура тогда лежат в одном месте, и правка делается один раз.
Свой CSS-класс уместен, когда речь об одном простом элементе и компонент ощущается избыточным, или когда нужно оформить чужую разметку — сторонний виджет, HTML из CMS. Такие классы кладут в слой components, а @apply позволяет собрать их из тех же утилит и токенов:
@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>Мы проверили это в браузере: у ссылки остались отступы 10px 16px и жирность 600 из .btn, а скругление стало 0px — утилита rounded-none победила. Причина — порядок слоёв: Tailwind объявляет theme, base, components, utilities, и утилиты всегда сильнее компонентных классов, независимо от порядка классов в атрибуте.
Повторяется блок из нескольких элементов (карточка, строка таблицы)
- Решение
- Компонент или partial шаблона
Повторяется в списке
- Решение
- Цикл в разметке — классы пишутся один раз
Один простой элемент в шаблонизаторе без компонентов
- Решение
- Класс в
@layer components, можно через@apply
Разметку генерирует чужой код (виджет, Markdown из CMS)
- Решение
- Свой CSS с
var(--color-…)или@apply
Два класса спорят за одно свойство (grid и flex)
- Решение
- Оставить один, выбирать его условием в коде
Проверка и упражнения
$ 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- Мобильная версия написана без префиксов, а
sm:/md:/lg:только добавляют изменения - Ни на одной ширине нет горизонтальной прокрутки
- У всех интерактивных элементов виден фокус с клавиатуры (
focus-visible:) - Hover — улучшение: без наведения всё по-прежнему понятно и доступно
- Цвета бренда и шрифты лежат в
@theme, а не повторяются произвольными значениями вродеbg-[#2067da] - У каждого светлого цвета есть пара в тёмной теме, переключатель запоминает выбор
- Имена классов в коде полные, без сборки из кусочков
- Повторяющиеся блоки вынесены в компоненты или циклы
Как проверено6 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
Проект Vite: vite.config.js, src/style.css, src/main.js, index.html
Vite 8.3.4 + tailwindcss 4.3.3 + @tailwindcss/vite 4.3.3 (npm view), vite build OK (CSS 14.18 kB / gzip 3.68 kB, вывод в статье реальный); код статьи считан генератором из файлов проекта; структура совпадает с шаблоном create-vite 9.2.1 --template vanilla (проверено отдельным прогоном)
- запущено, работает
Сгенерированный CSS
vite build --minify false: переменные @theme в @layer theme, md:grid-cols-2 в @media, hover: внутри @media (hover: hover), dark: через :where(.dark, .dark *); bg-red-500 и палитра red отсутствуют; фрагменты в статье взяты из файла сборки
- запущено, работает
Адаптивность, состояния и тёмная тема в браузере
playwright-core + headless Edge на vite preview: колонки 1/2/3 при 375/800/1280px, без горизонтальной прокрутки; системная тёмная тема → html.dark; клик переключателя: фон slate-50 → slate-950, localStorage.theme=dark, aria-pressed=true, сохраняется после перезагрузки; Tab → видимая рамка 2px, disabled: opacity 0.6 и not-allowed; group-hover перекрашивает заголовок; скриншоты осмотрены
- запущено, работает
@layer components + @apply (.btn) и переопределение rounded-none
отдельная копия проекта: сборка OK, в браузере у ссылки padding 10px 16px и font-weight 600 из .btn, border-radius 0px от утилиты
- запущено, работает
Та же тема в Next.js 16.4
style.css урока подставлен в app/globals.css проекта create-next-app 16.4 (Tailwind через @tailwindcss/turbopack), временная страница: next build OK, в CSS есть bg-brand-600, dark:bg-slate-900:where(.dark,.dark *), hover в @media (hover:hover), md:px-6; oklch продублированы hex; изменения откатены
- запущено, работает
Упражнения: акцентный цвет, семантические токены
accent: сборка, в CSS --color-accent-500 и .bg-accent-500; токены (код решения = файл проекта tw-tokens): сборка, фон карточек rgb(255,255,255) в светлой и oklch(0.208 0.042 265.755) в тёмной теме
- не запускалось
Упражнение «Карточка услуги»
самостоятельная задача без готового кода в статье
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.