Tailwind

Варіанти: hover, focus, responsive, dark mode та нові v4

Повний академічний розбір системи варіантів Tailwind CSS v4: стани взаємодії, structural pseudo-classes, group та peer, нові варіанти has-*, not-*, nth-*, starting:, адаптивні breakpoints та dark mode. З живими прикладами та практичними завданнями.

Варіанти: hover, focus, responsive, dark mode та нові v4

Що таке варіант: від CSS до декларативного UI

Щоб зрозуміти концепцію варіантів у Tailwind, необхідно спочатку звернутись до фундаментальної проблеми, яку вони вирішують. У традиційному CSS для опису різних станів елемента розробник писав окремі блоки правил: селектор для базового стану, .btn:hover {} для наведення, .btn:focus-visible {} для клавіатурного фокусу, @media (prefers-color-scheme: dark) {} для темної теми. Логіка одного елемента розпорошувалась між кількома файлами і декількома місцями в CSS.

Tailwind пропонує принципово інший підхід: уся поведінка елемента описується безпосередньо в class-атрибуті HTML. Кожен клас є комбінацією двох частин — умови та CSS-правила:

{варіант}:{утиліта} = {умова, за якої CSS застосовується} + {саме CSS-правило}

Наприклад, hover:bg-indigo-600 — це не просто колір, це декларативне твердження: «фон стає indigo-600 у момент, коли користувач наводить курсор на цей елемент». Читаючи HTML, ви бачите повну поведінку елемента без необхідності перегортати CSS-файл.

Ще важливіше те, що варіанти можна стекати — комбінувати кілька умов в один клас: dark:hover:bg-indigo-700 застосовує колір лише тоді, коли одночасно активна темна тема і елемент у стані hover. Це дозволяє описати найскладнішу умовну логіку у декларативній формі без жодного рядка JavaScript.

Tailwind не розрізняє поняття «стан» та «варіант» — вони синонімічні. Технічно варіант — це будь-який модифікатор, що змінює CSS-селектор або загортає правило у медіа-запит.

Варіанти взаємодії: станові псевдокласи

hover, focus, active: тріада базової взаємодії

Три найбільш вживані варіанти відповідають трьом базовим станам взаємодії користувача з елементом. hover: застосовується, коли курсор миші знаходиться над елементом. focus: — коли елемент отримав фокус (через Tab або клік). active: — у момент активного натискання, між mousedown і mouseup.

Разом вони описують повний цикл взаємодії з інтерактивним елементом:

<!-- Базова кнопка з трьома станами взаємодії -->
<button class="
    bg-indigo-600
    hover:bg-indigo-700
    active:bg-indigo-800 active:scale-95
    focus-visible:outline-none focus-visible:ring-2
    focus-visible:ring-indigo-500 focus-visible:ring-offset-2
    transition-all duration-150
">
    Натисніть мене
</button>

Зверніть увагу на ключову відмінність між focus: та focus-visible:. Псевдоклас :focus спрацьовує при будь-якому отриманні фокусу — включно з кліком мишею, що є небажаним для кнопок (обведення з'являтиметься після кожного кліку). Псевдоклас :focus-visible є розумнішим: він спрацьовує лише тоді, коли браузер визначає, що індикатор фокусу є корисним для користувача — тобто при навігації клавіатурою. Це робить focus-visible: стандартом accessibility у сучасному UI.

visited, disabled, checked, required, invalid

Окрім тріади основних станів, Tailwind надає варіанти для всіх стандартних CSS-псевдокласів:

<!-- Відвідані посилання отримують інший колір -->
<a href="/page" class="text-indigo-600 visited:text-purple-600 underline">
    Посилання
</a>

<!-- Вимкнені елементи — зниження прозорості та зміна курсору -->
<button
    class="bg-indigo-500 text-white px-4 py-2 rounded-lg
           disabled:opacity-50 disabled:cursor-not-allowed
           disabled:pointer-events-none"
    disabled
>
    Вимкнено
</button>

<!-- Checkbox — різний вигляд залежно від стану -->
<input
    type="checkbox"
    class="w-4 h-4 rounded border-gray-300
           checked:border-indigo-500 checked:bg-indigo-500
           focus-visible:ring-2 focus-visible:ring-indigo-500"
/>

<!-- Валідація email у реальному часі -->
<input
    type="email"
    class="border border-gray-300 rounded-lg px-3 py-2
           invalid:border-red-500 invalid:bg-red-50
           valid:border-green-500
           focus:ring-2 focus:ring-indigo-500 focus:outline-none
           transition-colors"
    placeholder="name@example.com"
/>
invalid: спрацьовує лише після того, як елемент вперше втратив фокус або форма намагалась відправитись. Для активної валідації в процесі друку — поєднуйте з :not(:placeholder-shown).

placeholder, file, selection: спеціальні псевдоелементи

<!-- Стилізація тексту-підказки в input -->
<input
    class="placeholder:text-gray-400 placeholder:italic placeholder:text-sm"
    placeholder="Введіть ваш email..."
/>

<!-- Кнопка вибору файлу — повна кастомізація без JS -->
<input
    type="file"
    class="file:mr-4 file:py-2 file:px-4
           file:rounded-lg file:border-0
           file:text-sm file:font-semibold
           file:bg-indigo-50 file:text-indigo-700
           hover:file:bg-indigo-100 file:cursor-pointer
           file:transition-colors"
/>

<!-- Колір виділення тексту -->
<p class="selection:bg-indigo-500 selection:text-white leading-relaxed">
    Виділіть цей текст мишею — ви побачите брендовий колір виділення.
    Це один клас замість глобального ::selection правила.
</p>
🔒localhost:3000

🧪 Практика: інтерактивна форма

Створіть форму реєстрації з трьома полями: name (required), email (type="email"), password (minlength="8"). Застосуйте варіанти: invalid:border-red-400 для невалідного стану, valid:border-green-400 для валідного, placeholder:text-gray-400 placeholder:italic для підказок. Кнопка Submit — disabled:opacity-50 disabled:cursor-not-allowed. Протестуйте кожен стан у браузері.


Структурні псевдокласи: позиційна стилізація

first, last, odd, even: патерни списків

Структурні псевдокласи дозволяють стилізувати елементи на основі їхньої позиції у батьківському контейнері без прив'язки до конкретних CSS-класів або inline-стилів. Ці варіанти особливо корисні при роботі зі списками, таблицями та повторюваними компонентами, де кількість елементів визначається динамічно.

<ul class="divide-y divide-slate-200 border border-slate-200 rounded-xl overflow-hidden">
    <!-- first: — перший елемент без верхнього відступу -->
    <!-- last: — останній без нижнього відступу -->
    <!-- odd: — непарні елементи з іншим фоном -->
    <!-- even: — парні елементи з іншим фоном -->
    <li class="py-3 px-4
               first:rounded-t-xl last:rounded-b-xl
               odd:bg-white even:bg-slate-50
               hover:bg-indigo-50 transition-colors">
        Пункт списку
    </li>
    <!-- ... повторюється -->
</ul>

Ці варіанти мають одну важливу характеристику: вони є CSS-native — тобто не вимагають жодного JavaScript і не залежать від кількості елементів. Якщо ви додасте або видалите елемент зі списку, стилізація автоматично оновиться.

nth-child у Tailwind v4: гнучка позиційна стилізація

Tailwind v4 суттєво розширив можливості позиційної стилізації, додавши повноцінну підтримку :nth-child() з різними форматами аргументів:

<!-- Кожен 2-й елемент (тобто парні) -->
<div class="nth-2:bg-slate-100 p-3">...</div>

<!-- Кожен 3-й елемент -->
<div class="nth-3:opacity-50 p-3">...</div>

<!-- Складний математичний вираз: кожен непарний -->
<div class="nth-[2n+1]:bg-indigo-50 p-3">...</div>

<!-- З фільтром класу: кожен 2-й елемент .card, ігноруючи решту -->
<div class="nth-[2n_of_.card]:bg-slate-50 p-3">...</div>
🔒localhost:3000

Синтаксис nth-[expr] надає доступ до повної потужності CSS :nth-child(An+B) — він дозволяє описувати будь-які повторювані патерни з математичною точністю.

only, empty, first-of-type: рідше, але важливо

<!-- Показати спеціальний UI якщо елемент єдиний у батьку -->
<p class="hidden only:block text-center text-slate-400 italic py-8">
    Список порожній. Додайте перший елемент.
</p>

<!-- Приховати порожній контейнер -->
<div class="empty:hidden border border-slate-200 rounded-lg p-4">
    <!-- якщо тут нічого немає — div не займатиме місце -->
</div>

<!-- Перший рядок таблиці певного типу -->
<tr class="first-of-type:bg-slate-50 first-of-type:font-semibold">
    <td>...</td>
</tr>
empty: визначає порожнечу суворо: пробіли та переноси рядків у HTML вважаються контентом. Якщо <div></div> — порожній, <div> </div> — ні.
🔒localhost:3000

🧪 Практика: таблиця з зебра-стилями

Створіть HTML-таблицю з 6+ рядками даних. Застосуйте: odd:bg-white even:bg-slate-50 до рядків, first:rounded-t-xl last:rounded-b-xl до крайніх рядків, hover:bg-indigo-50 transition-colors до кожного рядка. Спробуйте також nth-3:font-bold і переконайтеся, що кожен третій рядок виділяється жирним шрифтом.


Псевдоелементи: before, after та спеціальні

before та after: декоративний контент через CSS

Псевдоелементи ::before та ::after дозволяють вставляти декоративний контент безпосередньо через CSS, без додаткових HTML-елементів. Tailwind надає варіанти before: та after: для повного контролю над цими псевдоелементами, включно з властивістю content.

<!-- Декоративна стрілка перед текстом -->
<li class="before:content-['→'] before:mr-2 before:text-indigo-500 before:font-bold">
    Пункт списку зі стрілкою
</li>

<!-- Обов'язкове поле — зірочка після label -->
<label class="after:content-['*'] after:text-red-500 after:ml-0.5 font-medium">
    Email
</label>

<!-- Лічильник з data-атрибута -->
<button
    class="relative before:content-[attr(data-count)]
           before:absolute before:-top-1.5 before:-right-1.5
           before:bg-red-500 before:text-white
           before:text-xs before:font-bold
           before:w-4 before:h-4 before:rounded-full
           before:flex before:items-center before:justify-center"
    data-count="3"
>
    Сповіщення
</button>
Властивість content у before:content-['текст'] підтримує CSS-функцію attr(): before:content-[attr(data-label)] підставить значення атрибута data-label елемента.

marker, first-line, first-letter, backdrop

<!-- Кастомний маркер списку -->
<ul class="list-disc marker:text-indigo-500 marker:text-lg space-y-1">
    <li>Перший пункт</li>
    <li>Другий пункт</li>
</ul>

<!-- Перший рядок параграфу — ефект газетної верстки -->
<p class="first-line:uppercase first-line:tracking-widest
          first-line:text-indigo-600 first-line:font-semibold
          leading-relaxed text-slate-700">
    Перший рядок буде стилізований особливим чином.
    Решта тексту виглядає звичайно.
</p>

<!-- Перша літера — класичний ефект капітелі (drop cap) -->
<p class="first-letter:text-5xl first-letter:font-black
          first-letter:float-left first-letter:mr-2
          first-letter:text-indigo-600 first-letter:leading-none
          leading-relaxed">
    Давним-давно у далекій галактиці розробники
    писали CSS без utility-фреймворків...
</p>

<!-- Backdrop для нативного <dialog> елемента -->
<dialog class="backdrop:bg-slate-900/60 backdrop:backdrop-blur-sm
               rounded-2xl shadow-2xl p-8">
    <p>Контент діалогу з затемненим фоном</p>
</dialog>
🔒localhost:3000

🧪 Практика: декоративна типографіка

Реалізуйте три типографічні ефекти: (1) список із кастомними emoji-маркерами через before:content-['✦'], (2) параграф із first-letter: ефектом drop cap у стилі журнальної статті, (3) label форми із червоною зірочкою через after:content-['*'] after:text-red-500. Всі три — без жодного HTML, лише CSS через Tailwind.


group та peer: реакція на стан сусідніх і батьківських елементів

group: каскадний hover від батька до нащадків

Одна з найпотужніших можливостей системи варіантів Tailwind — це варіант group, що дозволяє дочірнім елементам реагувати на стан батьківського контейнера. Без цього механізму єдиний спосіб реалізувати таку поведінку — JavaScript. З group це вирішується виключно через CSS.

Принцип роботи: батьківський елемент отримує клас group (без жодного CSS-ефекту, лише як маркер). Дочірні елементи використовують префікс group-hover:, group-focus:, group-active: — і реагують на відповідний стан батька:

<!-- group на батьку — маркер без власних стилів -->
<div class="group p-4 bg-white rounded-xl border border-slate-200
            hover:border-indigo-300 hover:bg-indigo-50 transition-all cursor-pointer">

    <!-- Іконка — змінює фон при hover батька -->
    <div class="size-10 bg-indigo-100 group-hover:bg-indigo-500
                rounded-lg flex items-center justify-center
                text-xl transition-colors mb-3">
        🎨
    </div>

    <!-- Заголовок — змінює колір при hover батька -->
    <h3 class="font-bold text-slate-800 group-hover:text-indigo-700
               transition-colors">
        Дизайн-система
    </h3>

    <!-- Текст — змінює прозорість і колір -->
    <p class="text-sm text-slate-500 group-hover:text-indigo-500/70
              transition-colors mt-1">
        Токени, компоненти, утиліти
    </p>

    <!-- Стрілка — прихована до hover, з'являється плавно -->
    <span class="inline-block mt-3 text-sm font-semibold text-indigo-500
                 opacity-0 group-hover:opacity-100
                 translate-x-0 group-hover:translate-x-1
                 transition-all">
        Детальніше →
    </span>
</div>

group/{name}: іменовані групи для вкладених структур

Проблема виникає, коли group-елементи вкладені один в одного: дочірній елемент не може розрізнити, реакція якого саме батька йому потрібна — він реагуватиме на будь-який group-батько у ланцюжку. Tailwind вирішує це через іменовані групи:

<!-- Зовнішній контейнер — group/card -->
<div class="group/card p-4 bg-white rounded-xl border hover:border-indigo-200 transition-colors">

    <!-- Внутрішній контейнер — group/btn -->
    <div class="group/btn flex items-center gap-2 cursor-pointer">
        <button class="px-3 py-1.5 bg-slate-100 rounded-lg text-sm font-medium
                       group-hover/btn:bg-indigo-500
                       group-hover/btn:text-white transition-colors">
            Дія
        </button>
        <!-- Реагує ТІЛЬКИ на hover .group/btn, не на .group/card -->
        <span class="opacity-0 group-hover/btn:opacity-100
                     text-xs text-indigo-500 transition-opacity">
            Натисніть
        </span>
    </div>

    <!-- Реагує ТІЛЬКИ на hover .group/card, не на .group/btn -->
    <p class="mt-3 text-xs text-slate-400
              opacity-0 group-hover/card:opacity-100 transition-opacity">
        Картка активна
    </p>
</div>

Синтаксис group/{name} та group-hover/{name} забезпечує точну прив'язку: кожен дочірній елемент явно вказує, на якого батька він реагує.

peer: реакція наступного сусіда на стан попереднього

Варіант peer вирішує симетричну задачу: наступний сусідній елемент реагує на стан попереднього. Найкласичніший приклад — toggle switch без JavaScript:

<!-- peer на input — маркер, аналогічний group -->
<input id="toggle" type="checkbox" class="peer sr-only" />

<!-- label ПІСЛЯ input у DOM — реагує на стан peer -->
<label for="toggle" class="flex items-center gap-3 cursor-pointer select-none">
    <div class="relative">
        <!-- Трек перемикача -->
        <div class="w-11 h-6 bg-slate-300 peer-checked:bg-indigo-500
                    rounded-full transition-colors duration-200">
        </div>
        <!-- Бігунок -->
        <div class="absolute top-0.5 left-0.5 size-5 bg-white rounded-full shadow
                    transition-transform duration-200
                    peer-checked:translate-x-5">
        </div>
    </div>
    <span class="text-sm font-medium text-slate-700
                 peer-checked:text-indigo-600 transition-colors">
        Увімкнути сповіщення
    </span>
</label>
Критично важливий порядок у DOM: peer-елемент має стояти ДО елемента, що реагує. CSS не може «дивитись назад» на попередні сусіди. Якщо елемент стоїть після peer-*-реагуючого елемента — він не спрацює.

Аналогічно до group, peer підтримує іменування: peer/name та peer-checked/name — для ситуацій із кількома peer-елементами на одному рівні.

🔒localhost:3000

🧪 Практика: інтерактивна картка та toggle

Реалізуйте два компоненти. Перший — картка товару з group: при наведенні повинні одночасно змінюватися фон, колір заголовку та з'являтися кнопка «Додати у кошик» з opacity-0 group-hover:opacity-100. Другий — список налаштувань із трьома toggle-перемикачами через peer-checked:. Жодного JavaScript — лише CSS-варіанти.


has-*: батьківський селектор у Tailwind v4

Проблема, яку вирішує :has()

До появи CSS :has() розробники не мали нативного способу стилізувати батьківський елемент на основі стану його нащадків. Єдиним рішенням був JavaScript: відстежувати стан дочірніх елементів і вручну додавати/видаляти класи на батьку. Це породжувало ненадійний, залежний від JS код для суто CSS-задач.

Варіант has-[selector]: у Tailwind v4 відкриває доступ до :has() через знайомий синтаксис префіксів:

<!-- Картка змінює layout, якщо містить зображення -->
<div class="flex flex-col has-[img]:flex-row gap-4 p-4 bg-white rounded-xl border">
    <img src="photo.jpg" class="w-32 h-24 object-cover rounded-lg" />
    <div>
        <h3 class="font-bold">Заголовок</h3>
        <p class="text-sm text-slate-500">Опис</p>
    </div>
</div>

<!-- Label підсвічується при фокусі вкладеного input — без JS! -->
<label class="block text-sm font-semibold text-slate-700
              has-[:focus]:text-indigo-600 transition-colors">
    Email адреса
    <input type="email"
           class="mt-1 block w-full border border-slate-300 rounded-lg px-3 py-2
                  focus:outline-none focus:border-indigo-500" />
</label>

<!-- Форма підсвічує рамку, якщо є заповнений input -->
<form class="border-2 border-slate-200 rounded-xl p-4
             has-[input:not(:placeholder-shown)]:border-indigo-400 transition-colors">
    <input type="text" placeholder="Почніть вводити..."
           class="w-full focus:outline-none text-sm" />
</form>

<!-- Nav підсвічується якщо є активний пункт -->
<nav class="bg-white border-b
            has-[.active]:border-indigo-500 has-[.active]:bg-indigo-50 transition-colors">
    <a href="/" class="px-4 py-3 block active">Головна</a>
    <a href="/about" class="px-4 py-3 block">Про нас</a>
</nav>

Різниця між has-*, group-* та peer-*

Три механізми вирішують схожі, але різні задачі. Розуміння кожного є ключем до правильного вибору:

group-hover:
батько → нащадки
Нащадок реагує на стан батька. Потрібно явно позначити батька класом group. Реагує на hover, focus, active батька.
peer-checked:
попередній → наступний
Наступний сусід реагує на стан попереднього. Потрібно позначити попередній елемент класом peer. Реагує на checked, focus, hover попереднього.
has-[selector]:
батько → сам батько (на основі нащадків)
Батько стилізує сам себе на основі стану нащадка. Не потрібні додаткові класи на нащадках. Підтримує будь-який CSS-селектор.
🔒localhost:3000

🧪 Практика: розумна форма із has-*

Реалізуйте форму пошуку, де: (1) <label> змінює колір при фокусі вкладеного <input> через has-[:focus]:text-indigo-600, (2) блок результатів показується тільки якщо є текст через has-[input:not(:placeholder-shown)]:block (за замовчуванням hidden), (3) кнопка очищення з'являється аналогічно. Жодного JavaScript.


not-* та in-*: нові варіанти Tailwind v4

not-*: умова-заперечення

Варіант not-* застосовує стилі лише тоді, коли задана умова не виконується. Він відображає CSS :not() псевдоклас і з'явився у Tailwind v4:

<!-- Усі елементи крім першого мають верхній розділювач -->
<li class="not-first:border-t border-slate-200 py-3 px-4">...</li>

<!-- Всі кнопки крім disabled мають hover-ефект -->
<button class="bg-indigo-500 not-disabled:hover:bg-indigo-600
               disabled:opacity-50 transition-colors">
    Дія
</button>

<!-- Посилання без класу .active — приглушені -->
<a class="not-[.active]:opacity-60 not-[.active]:hover:opacity-100
          transition-opacity">
    Пункт навігації
</a>

<!-- Всі input крім checkbox і radio -->
<input class="not-[type=checkbox]:not-[type=radio]:border not-[type=checkbox]:not-[type=radio]:rounded-lg">

not-* особливо корисний у поєднанні з first: і last: як альтернативний спосіб запису: not-first:border-t є семантично чіткішим за [&:not(:first-child)]:border-t.

in-*: контекстна залежність

Варіант in-[selector]: застосовує стилі, коли елемент знаходиться всередині певного контейнера. На відміну від group-*, він не вимагає явного маркування батька:

<!-- Посилання всередині .prose мають підкреслення -->
<a class="text-indigo-600
          in-[.prose]:text-slate-700 in-[.prose]:underline in-[.prose]:underline-offset-2">
    Посилання
</a>

<!-- Кнопка в nav vs у картці — різний розмір -->
<button class="font-semibold text-sm
               in-[nav]:text-xs in-[nav]:px-2 in-[nav]:py-1
               in-[.card]:text-base in-[.card]:px-4 in-[.card]:py-2">
    Кнопка
</button>

<!-- Текст усередині selected item -->
<span class="text-slate-600 in-[[aria-selected=true]]:text-white in-[[aria-selected=true]]:font-semibold">
    Пункт списку
</span>
🔒localhost:3000
in-[selector]: генерує CSS вигляду .selector .element. Це означає, що він шукає будь-якого предка з вказаним селектором, а не лише прямого батька. Якщо потрібен лише прямий батько — використовуйте [.parent_&]:.

🧪 Практика: контекстно-залежний компонент

Створіть компонент <Badge> (бейдж), який виглядає по-різному залежно від контексту: у <nav> — маленький і прозорий (in-[nav]:text-xs in-[nav]:opacity-75), у .card — стандартний розмір, у .hero — великий і виразний. Один HTML-елемент — три різних вигляди через in-*.


starting:: анімація першої появи в DOM

Проблема анімації при mount

До появи @starting-style CSS не мав нативного способу анімувати елемент у момент його першої появи в DOM. Якщо елемент вже існував і отримував клас — transition спрацьовував. Але якщо елемент щойно додавався через display: block або visibility: visible — браузер не знав, від якого стану анімувати.

CSS @starting-style вирішує це: він дозволяє задати початковий стан для CSS-переходу при першій появі елемента. Tailwind v4 надає цю можливість через варіант starting::

<!-- Елемент появляється анімовано — без JS! -->
<div class="opacity-100 translate-y-0 transition-all duration-400 ease-out
            starting:opacity-0 starting:translate-y-4">
    Цей блок анімується при появі в DOM
</div>

<!-- Dialog з анімацією появи -->
<dialog class="opacity-100 scale-100 transition-all duration-300
               starting:opacity-0 starting:scale-95"
        open>
    <p>Контент діалогу — появляється з масштабуванням</p>
</dialog>

<!-- Notification toast -->
<div class="opacity-100 translate-x-0 transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]
            starting:opacity-0 starting:translate-x-8">
    ✅ Збережено успішно
</div>

Підтримка браузерів та fallback

На момент виходу Tailwind v4 @starting-style підтримувався Chrome 117+, Firefox 129+, Safari 17.5+. Для старіших браузерів варіант starting: просто ігнорується — елемент з'являється без анімації, що є коректним graceful degradation.

🔒localhost:3000

🧪 Практика: анімована поява елементів

Реалізуйте список новин (3 картки) з ефектом появи через starting:. Кожна картка має мати starting:opacity-0 starting:translate-y-4 і різний animation-delay (0ms, 100ms, 200ms). Також реалізуйте <dialog> зі starting:opacity-0 starting:scale-95 — нативна анімація відкриття без JavaScript.


Адаптивні варіанти: mobile-first система breakpoints

Філософія mobile-first та breakpoint-варіанти

Tailwind реалізує mobile-first підхід до адаптивного дизайну, що означає: стилі без breakpoint-префіксу застосовуються на всіх розмірах екрану (включно з мобільними), а breakpoint-варіанти застосовують стилі від визначеної ширини і вище:

ВаріантCSS-умоваЗначення
(none)всі розміри
sm:min-width: 40rem≥ 640px
md:min-width: 48rem≥ 768px
lg:min-width: 64rem≥ 1024px
xl:min-width: 80rem≥ 1280px
2xl:min-width: 96rem≥ 1536px

Читати responsive-клас потрібно як «від цього breakpoint — такий стиль»:

<!-- Сітка: мобільна 1 колонка → планшет 2 → десктоп 4 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
    <!-- Текст: малий на мобільному, великий на десктопі -->
    <h1 class="text-2xl sm:text-3xl lg:text-5xl font-black leading-tight">
        Адаптивний заголовок
    </h1>

    <!-- Flex: вертикальний на мобільному, горизонтальний на md+ -->
    <div class="flex flex-col md:flex-row items-start md:items-center gap-4">
        ...
    </div>

    <!-- Видимість: приховано на мобільному, видно на lg+ -->
    <nav class="hidden lg:flex items-center gap-6">
        ...
    </nav>
</div>

Ключовий принцип: починайте зі стилів для мобільного, потім додавайте варіанти для більших екранів. Не навпаки. Це відповідає порядку CSS-медіа-запитів і забезпечує коректне каскадування.

max-*: варіанти обмеження зверху (Tailwind v4)

До Tailwind v4 не існувало зручного способу застосувати стиль «лише до певного breakpoint» — доводилось поєднувати два варіанти або використовувати довільні значення. Tailwind v4 ввів max-*-варіанти, що відповідають max-width медіа-запитам:

<!-- Приховано тільки на мобільному (до sm) -->
<div class="max-sm:hidden">Видно тільки від sm і вище</div>

<!-- Різний padding: великий на мобільному, нормальний на md+ -->
<section class="max-md:px-6 px-0">...</section>

<!-- Тільки між md і lg -->
<div class="hidden md:block lg:hidden">Тільки на md</div>

<!-- Або через max-lg: -->
<div class="hidden md:max-lg:block">Тільки на md (md ≤ viewport < lg)</div>

<!-- Текст: великий тільки на мобільному (до sm) -->
<h1 class="text-4xl max-sm:text-2xl font-black">
    Великий на мобільному
</h1>

Синтаксис md:max-lg:block — це стекання двох breakpoint-варіантів, що разом утворюють вікно від md до lg.

🔒localhost:3000

🧪 Практика: повністю адаптивний лендінг

Реалізуйте hero-секцію лендінгу: мобільний — центрований текст, flex-col, великий шрифт. sm: — вирівнювання ліворуч, трохи менший шрифт. lg: — двоколонковий layout (grid-cols-2), зображення поряд із текстом. Кнопки: w-full sm:w-auto. Навбар: на мобільному hidden, на lg:flex.


Dark Mode варіант: від prefers-color-scheme до семантичних токенів

Автоматичний dark mode через системні налаштування

За замовчуванням варіант dark: у Tailwind v4 відповідає CSS медіа-запиту @media (prefers-color-scheme: dark). Це означає, що стилі з dark: застосовуються автоматично, якщо операційна система або браузер користувача налаштовані на темний режим:

<!-- Автоматичний dark: через OS -->
<div class="bg-white dark:bg-slate-900 text-slate-900 dark:text-white">
    <h1 class="text-slate-800 dark:text-slate-100">Заголовок</h1>
    <p class="text-slate-600 dark:text-slate-400">Основний текст</p>
    <a href="#" class="text-indigo-600 dark:text-indigo-400">Посилання</a>
</div>

Цей підхід найпростіший в реалізації, але має суттєвий недолік: користувач не може вручну перемкнути тему всередині вашого сайту незалежно від системних налаштувань.

Ручне перемикання через CSS selector

Для ручного перемикання теми у main.css потрібно переоголосити варіант dark: через @custom-variant:

@import 'tailwindcss';

/* dark: тепер спрацьовує за наявністю .dark на батьківському елементі */
@custom-variant dark (&:is(.dark *));
<html class="dark">
    <!-- .dark на <html> — весь документ у темному режимі -->
    <body>
        <div class="bg-white dark:bg-slate-900">...</div>
    </body>
</html>
// Перемикання теми одним рядком
document.documentElement.classList.toggle('dark')

// Збереження в localStorage
const isDark = document.documentElement.classList.toggle('dark')
localStorage.setItem('theme', isDark ? 'dark' : 'light')

// Відновлення при завантаженні
if (localStorage.getItem('theme') === 'dark' ||
    (!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
    document.documentElement.classList.add('dark')
}

Архітектурний підхід: семантичні токени замість dark: скрізь

Додавання dark:-дублікату до кожного кольорового класу — це антипатерн, що призводить до роздутого і важкого в обслуговуванні HTML. Уявіть картку з 15 кольоровими класами: їх треба продублювати всі. А якщо потрібно змінити колір фону темної теми — оновити кожен елемент окремо.

Правильний підхід — семантичні CSS-токени, де dark: застосовується лише один раз — до :root:

/* main.css */
@import 'tailwindcss';

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

@layer base {
    :root {
        /* Семантичні токени — light theme */
        --color-bg:      oklch(1 0 0);           /* white */
        --color-surface: oklch(0.976 0.002 247); /* slate-50 */
        --color-text:    oklch(0.129 0.042 264); /* slate-900 */
        --color-muted:   oklch(0.554 0.046 257); /* slate-500 */
        --color-border:  oklch(0.928 0.006 264); /* slate-200 */
        --color-accent:  oklch(0.585 0.233 277); /* indigo-500 */
    }

    .dark {
        /* Ті самі токени — інші значення */
        --color-bg:      oklch(0.129 0.042 264); /* slate-900 */
        --color-surface: oklch(0.208 0.042 264); /* slate-800 */
        --color-text:    oklch(0.967 0.001 286); /* slate-100 */
        --color-muted:   oklch(0.704 0.04 256);  /* slate-400 */
        --color-border:  oklch(0.372 0.044 264); /* slate-700 */
        --color-accent:  oklch(0.673 0.182 277); /* indigo-400 */
    }
}
<!-- Без єдиного dark: класу! Все через CSS-змінні -->
<div class="bg-(--color-bg) text-(--color-text) border border-(--color-border)">
    <h1>Автоматична тема</h1>
    <p class="text-(--color-muted)">Текст адаптується автоматично</p>
    <button class="bg-(--color-accent) text-white px-4 py-2 rounded-lg">
        Акцентна кнопка
    </button>
</div>
bg-(--color-bg) — синтаксис Tailwind v4 для CSS Custom Properties. Він генерує background-color: var(--color-bg). Еквівалент старішого синтаксису bg-[var(--color-bg)].
🔒localhost:3000

🧪 Практика: повноцінний темний режим

Реалізуйте сторінку профілю з: (1) @custom-variant dark (&:is(.dark *)) у CSS, (2) семантичними токенами у :root та .dark, (3) кнопкою-перемикачем, що зберігає вибір у localStorage та враховує системний prefers-color-scheme при першому завантаженні. Жодного dark: класу у HTML.


Стекання варіантів: комбінування умов

Принцип стекання

Варіанти можна комбінувати в ланцюжки будь-якої довжини. Кожен наступний варіант додає нову умову:

<!-- dark + hover: -->
<button class="bg-indigo-500 dark:bg-indigo-400
               hover:bg-indigo-600 dark:hover:bg-indigo-300">
    Кнопка
</button>

<!-- md + dark + hover: — три умови одночасно -->
<div class="md:dark:hover:bg-slate-700 transition-colors">
    Активно тільки на md+, у dark-режимі, при наведенні
</div>

<!-- group-hover + dark: -->
<div class="group ...">
    <span class="opacity-0 group-hover:opacity-100
                 dark:group-hover:opacity-80 transition-opacity">
        Текст з'являється при hover, приглушений у dark
    </span>
</div>

<!-- focus-visible + dark: — accessibility у dark-режимі -->
<input class="focus-visible:ring-2 focus-visible:ring-indigo-500
              dark:focus-visible:ring-indigo-400 focus-visible:outline-none" />

<!-- lg + peer-checked: — адаптивна реакція на peer -->
<div class="hidden lg:peer-checked:block">
    Показується при lg+, якщо peer перевірений
</div>
🔒localhost:3000

Порядок варіантів у класі не має значення для CSS-пріоритету, але за конвенцією рекомендується: responsive:state:pseudo:. Tailwind гарантує коректну генерацію CSS незалежно від порядку.

При великій кількості стекованих варіантів рядок може стати незрозумілим. Використовуйте @layer components з @apply для складних комбінацій: @apply dark:hover:bg-slate-800 md:dark:hover:bg-slate-900.

Довільні варіанти: вихід за межі вбудованих

Коли вбудованих варіантів не вистачає

Tailwind охоплює 95% типових потреб через вбудовані варіанти. Для решти — [&:selector]: синтаксис довільних варіантів, де & означає «поточний елемент»:

<!-- nth-child без утиліти — конкретно 3-й елемент -->
<li class="[&:nth-child(3)]:font-bold [&:nth-child(3)]:text-indigo-600">
    Я жирний і синій, тільки якщо є 3-м
</li>

<!-- Стилізувати вкладені p через батьківський елемент -->
<div class="[&_p]:text-slate-600 [&_p]:leading-relaxed [&_p]:mb-4">
    <p>Цей параграф отримає стилі через довільний варіант</p>
    <p>І цей також — без class на кожному p</p>
</div>

<!-- Media query для hover-спроможних пристроїв (не touch) -->
<div class="[@media(hover:hover)]:hover:opacity-75">
    Hover-ефект тільки там, де він реально є (не на сенсорних)
</div>

<!-- @supports — застосувати стилі якщо CSS-можливість підтримується -->
<div class="[@supports(display:grid)]:grid [@supports(display:grid)]:grid-cols-3">
    Grid-макет, тільки якщо підтримується grid
</div>

<!-- data-атрибут — стан через атрибут -->
<div class="data-[state=open]:rotate-180 transition-transform"
     data-state="open">
    Іконка-стрілка обертається при data-state="open"
</div>

<!-- aria-атрибут -->
<button class="aria-expanded:bg-indigo-50 aria-expanded:text-indigo-700"
        aria-expanded="true">
    Кнопка accordion — активна при aria-expanded="true"
</button>

data-* та aria-* варіанти

Два найпопулярніших типи довільних варіантів — data-[attr=value]: та aria-[attr=value]:. Вони дозволяють реалізувати state-based стилізацію через HTML-атрибути замість JavaScript-класів:

<!-- UI Library компонент — стан через data-атрибут -->
<li class="px-3 py-2 rounded-lg
           data-[active=true]:bg-indigo-50
           data-[active=true]:text-indigo-700
           data-[active=true]:font-semibold
           text-slate-600 cursor-pointer transition-colors"
    data-active="true">
    Активний пункт
</li>

<!-- Headless UI / Radix UI pattern -->
<button class="aria-[pressed=true]:bg-indigo-500 aria-[pressed=true]:text-white
               px-4 py-2 rounded-lg border border-slate-300 transition-colors"
        aria-pressed="false">
    Toggle Button
</button>
🔒localhost:3000

🧪 Практика: компонент із довільними варіантами

Реалізуйте список вкладок (tabs) де: активна вкладка визначається через data-[state=active]: атрибут (не class), клік через мінімальний JS лише перемикає data-state. Вміст вкладок — через data-[hidden=true]:hidden. Жодних класів active, hidden, selected — лише data-атрибути та довільні варіанти.


Шпаргалка: всі варіанти в одному місці


Завдання для самоперевірки


Попередня стаття: Кастомізація теми через @themeНаступна стаття: Типографіка та система кольорів

Copyright © 2026