Компоненти та повторюваність: @apply, @utility та патерни
Компоненти та повторюваність: @apply, @utility та патерни
Фундаментальна напруга: утилітарність проти повторюваності
Utility-first підхід Tailwind побудований на фундаментальному принципі: кожен CSS-клас описує одну атомарну властивість, а HTML-елемент отримує всі необхідні стилі через список таких класів безпосередньо в атрибуті class. Це дає разючу локальність стилів — читаючи HTML, ви бачите повну картину зовнішнього вигляду елемента без необхідності переглядати CSS-файли.
Однак у кожного розробника, що починає реальний проєкт, виникає однакове питання. Уявіть кнопку:
<button class="inline-flex items-center justify-center gap-2
px-5 py-2.5 font-semibold rounded-xl transition-colors
bg-indigo-600 hover:bg-indigo-700 active:scale-95
text-white shadow-sm hover:shadow-md">
Зберегти зміни
</button>
Якщо ця кнопка використовується у 40 місцях додатку — чи потрібно оновлювати 40 HTML-рядків, коли дизайнер попросить змінити rounded-xl на rounded-2xl? Відповідь: ні, але рішення залежить від контексту. Tailwind надає кілька підходів до вирішення цієї напруги, і вибір між ними є одним із ключових архітектурних рішень у вашому проєкті.
Рішення 0: JS/TS компоненти — найкраще у фреймворках
Якщо ви працюєте з React, Vue, Svelte, Angular або будь-яким іншим компонентним фреймворком — первинне і найелегантніше рішення проблеми повторюваності є компонент. Це не специфіка Tailwind — це загальний принцип компонентної архітектури.
Ідея проста: замість того, щоб керувати набором Tailwind-класів у CSS, ви інкапсулюєте їх у функцію або компонент. Кожен виклик компонента генерує правильний HTML із правильними класами, а варіанти (primary, secondary, ghost) визначаються через props:
// React: Button — повна система варіантів в одному компоненті
const buttonVariants = {
primary: 'bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm hover:shadow-md',
secondary: 'border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50',
ghost: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
danger: 'bg-red-500 hover:bg-red-600 text-white',
}
const buttonSizes = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-5 py-2.5 text-sm',
lg: 'px-7 py-3.5 text-base',
}
function Button({
children,
variant = 'primary',
size = 'md',
disabled = false,
className = '',
...props
}) {
return (
<button
className={`
inline-flex items-center justify-center gap-2
font-semibold rounded-xl transition-all
disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none
${buttonVariants[variant]}
${buttonSizes[size]}
${className}
`}
disabled={disabled}
{...props}
>
{children}
</button>
)
}
// Використання — жодного повторення класів
<Button>Зберегти</Button>
<Button variant="secondary">Скасувати</Button>
<Button variant="ghost" size="sm">Додатково</Button>
<Button variant="danger" disabled>Видалити</Button>
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="p-6 bg-slate-50" style="font-family: 'Inter', system-ui, sans-serif;">
<div class="max-w-md mx-auto bg-white rounded-2xl border border-slate-200 p-6 shadow-sm">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">React Component Props Simulator</p>
<!-- Interactive Preview -->
<div class="h-28 flex items-center justify-center border border-slate-100 bg-slate-50/50 rounded-xl mb-6 p-4">
<button id="simBtn" class="inline-flex items-center justify-center gap-2 font-semibold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm hover:shadow-md px-5 py-2.5 text-sm">
Зберегти зміни
</button>
</div>
<!-- Controls -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-2">Variant</label>
<select id="variantSelect" onchange="updateButton()" class="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500/20">
<option value="primary">primary</option>
<option value="secondary">secondary</option>
<option value="ghost">ghost</option>
<option value="danger">danger</option>
</select>
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-2">Size</label>
<select id="sizeSelect" onchange="updateButton()" class="w-full text-sm bg-slate-50 border border-slate-200 rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-indigo-500/20">
<option value="sm">sm (small)</option>
<option value="md" selected>md (medium)</option>
<option value="lg">lg (large)</option>
</select>
</div>
<div class="col-span-2 flex items-center gap-2 pt-2">
<input type="checkbox" id="disabledCheck" onchange="updateButton()" class="w-4 h-4 text-indigo-600 border-slate-300 rounded focus:ring-indigo-500/20">
<label for="disabledCheck" class="text-sm font-medium text-slate-700 select-none">disabled</label>
</div>
</div>
<!-- Active Classes Display -->
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-2">Active Class List</label>
<div class="bg-slate-900 text-slate-300 rounded-lg p-3 text-xs font-mono break-all leading-normal shadow-inner animate-pulse-once" id="classList">
inline-flex items-center justify-center gap-2 font-semibold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm hover:shadow-md px-5 py-2.5 text-sm
</div>
</div>
</div>
<script>
const btn = document.getElementById('simBtn');
const varSelect = document.getElementById('variantSelect');
const szSelect = document.getElementById('sizeSelect');
const disCheck = document.getElementById('disabledCheck');
const cList = document.getElementById('classList');
const buttonVariants = {
primary: 'bg-indigo-600 hover:bg-indigo-700 text-white shadow-sm hover:shadow-md',
secondary: 'border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50',
ghost: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
danger: 'bg-red-500 hover:bg-red-600 text-white'
};
const buttonSizes = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-5 py-2.5 text-sm',
lg: 'px-7 py-3.5 text-base'
};
function updateButton() {
const variant = varSelect.value;
const size = szSelect.value;
const isDisabled = disCheck.checked;
// Update disabled attribute
if (isDisabled) {
btn.setAttribute('disabled', 'true');
} else {
btn.removeAttribute('disabled');
}
// Assemble classes
const baseClasses = 'inline-flex items-center justify-center gap-2 font-semibold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none';
const varClass = buttonVariants[variant];
const szClass = buttonSizes[size];
const fullClass = `${baseClasses} ${varClass} ${szClass}`;
// Set classes on preview button
btn.className = fullClass;
// Update Class List display
cList.textContent = fullClass.replace(/\s+/g, ' ').trim();
}
</script>
</body>
</html>
Аналогічно для Vue (через :class binding), Svelte (через class: директиви) або Angular (через [ngClass]). Один компонент — одне місце для зміни. Щоб оновити rounded-xl → rounded-2xl для всіх кнопок, достатньо змінити один рядок.
class-variance-authority (CVA) — вона надає декларативний API для побудови variant-систем на основі Tailwind-класів: cva('base-classes', { variants: { ... } }). Це значно чистіше, ніж ручні об'єкти.Якщо ви не використовуєте компонентний фреймворк — розглядайте наступні рішення.
Рішення 1: @apply — перенесення класів у CSS
Директива @apply дозволяє витягти набір Tailwind-класів із HTML у CSS-правило. Це класичне рішення для проєктів без компонентних фреймворків — наприклад, для багатосторінкових сайтів на Laravel Blade, Django templates або статичних генераторах:
/* components.css */
@layer components {
/* Базова кнопка — спільні стилі для всіх варіантів */
.btn {
@apply inline-flex items-center justify-center gap-2
px-5 py-2.5 font-semibold rounded-xl
transition-all duration-150
disabled:opacity-50 disabled:cursor-not-allowed;
}
/* Варіанти — модифікатори до базового .btn */
.btn-primary {
@apply bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800
text-white shadow-sm hover:shadow-md;
}
.btn-secondary {
@apply border-2 border-indigo-600 text-indigo-600
hover:bg-indigo-50 active:bg-indigo-100;
}
.btn-ghost {
@apply text-slate-600 hover:bg-slate-100 hover:text-slate-900;
}
.btn-danger {
@apply bg-red-500 hover:bg-red-600 text-white;
}
/* Розміри */
.btn-sm { @apply px-3 py-1.5 text-xs rounded-lg; }
.btn-lg { @apply px-7 py-3.5 text-base rounded-2xl; }
}
<!-- HTML стає чистим і семантичним -->
<button class="btn btn-primary">Зберегти</button>
<button class="btn btn-secondary">Скасувати</button>
<button class="btn btn-ghost btn-sm">Додатково</button>
<button class="btn btn-danger" disabled>Видалити</button>
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Mimicking the CSS styles built via @apply in the article */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem; /* px-5 py-2.5 */
font-weight: 600;
border-radius: 0.75rem; /* rounded-xl */
font-size: 0.875rem; /* text-sm */
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: #4f46e5; /* bg-indigo-600 */
color: #ffffff;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); /* shadow-sm */
}
.btn-primary:hover:not(:disabled) {
background-color: #4338ca; /* hover:bg-indigo-700 */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); /* hover:shadow-md */
}
.btn-primary:active:not(:disabled) {
background-color: #3730a3; /* active:bg-indigo-800 */
}
.btn-secondary {
border-width: 2px;
border-color: #4f46e5; /* border-indigo-600 */
color: #4f46e5; /* text-indigo-600 */
background-color: transparent;
}
.btn-secondary:hover:not(:disabled) {
background-color: #f5f3ff; /* hover:bg-indigo-50 */
}
.btn-secondary:active:not(:disabled) {
background-color: #ede9fe; /* active:bg-indigo-100 */
}
.btn-ghost {
color: #475569; /* text-slate-600 */
background-color: transparent;
}
.btn-ghost:hover:not(:disabled) {
background-color: #f1f5f9; /* hover:bg-slate-100 */
color: #0f172a; /* hover:text-slate-900 */
}
.btn-danger {
background-color: #ef4444; /* bg-red-500 */
color: #ffffff;
}
.btn-danger:hover:not(:disabled) {
background-color: #dc2626; /* hover:bg-red-600 */
}
.btn-sm {
padding: 0.375rem 0.75rem; /* px-3 py-1.5 */
font-size: 0.75rem; /* text-xs */
border-radius: 0.5rem; /* rounded-lg */
}
.btn-lg {
padding: 0.875rem 1.75rem; /* px-7 py-3.5 */
font-size: 1rem; /* text-base */
border-radius: 1rem; /* rounded-2xl */
}
</style>
</head>
<body class="p-6 bg-slate-50" style="font-family: 'Inter', system-ui, sans-serif;">
<div class="max-w-xl mx-auto bg-white rounded-2xl border border-slate-200 p-6 shadow-sm space-y-6">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">@apply Component Style Showcase</p>
<!-- Grid of primary styled buttons -->
<div class="space-y-2">
<h4 class="text-xs font-semibold text-slate-400 uppercase">Button Variants (Normal / Disabled)</h4>
<div class="flex flex-wrap gap-3 items-center">
<button class="btn btn-primary">Зберегти</button>
<button class="btn btn-secondary">Скасувати</button>
<button class="btn btn-ghost">Додатково</button>
<button class="btn btn-danger">Видалити</button>
<button class="btn btn-primary" disabled>Блоковано</button>
</div>
</div>
<!-- Button sizes -->
<div class="space-y-2">
<h4 class="text-xs font-semibold text-slate-400 uppercase">Sizes</h4>
<div class="flex flex-wrap gap-3 items-center">
<button class="btn btn-primary btn-sm">Small (btn-sm)</button>
<button class="btn btn-primary">Medium (default)</button>
<button class="btn btn-primary btn-lg">Large (btn-lg)</button>
</div>
</div>
</div>
</body>
</html>
Обмеження @apply: варіанти та специфічність
@apply має важливе обмеження: він не рекомендується для складних варіантів. Хоча технічно @apply hover:bg-indigo-700 компілюється, це порушує принцип розділення — варіантна логіка потрапляє у CSS, де її важче помітити та перевизначити.
Правильний підхід: виносьте в @apply лише статичні базові стилі, а інтерактивну логіку залишайте у HTML:
/* ✅ Добре: @apply для статичних базових стилів */
.card {
@apply bg-white rounded-2xl border border-slate-200 p-6;
/* Без hover:, dark:, focus: у @apply */
}
/* Стилі для станів — через CSS нативно або у HTML */
.card:hover {
box-shadow: 0 8px 30px -8px oklch(0.1 0.05 264 / 0.15);
}
<!-- ✅ Добре: hover/dark/group варіанти залишаються у HTML -->
<div class="card hover:shadow-lg dark:bg-slate-800 dark:border-slate-700 transition-shadow">
...
</div>
@apply повертає вас до парадигми «CSS-файл із класами» — ви знову пишете іменовані CSS-класи, просто через Tailwind-синтаксис. Переваги Tailwind (локальність стилів у HTML, відсутність прихованих залежностей) частково зникають. Використовуйте @apply виважено: лише для справді повторюваних базових компонентів.Рішення 2: @utility — кастомні атомарні утиліти (Tailwind v4)
@utility — нова директива Tailwind v4, що дозволяє визначити власний utility-клас першого класу. На відміну від @layer components + @apply, клас, визначений через @utility, поводиться абсолютно так само, як вбудовані утиліти Tailwind: підтримує всі варіанти (hover:, dark:, md:, group-hover:, focus-visible: тощо).
Синтаксис та принцип роботи
/* @utility {ім'я} { CSS-властивості } */
@utility btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem; /* px-5 py-2.5 */
font-weight: 600;
border-radius: 0.75rem; /* rounded-xl */
transition-property: color, background-color, border-color, box-shadow, transform;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
@utility card {
background-color: white;
border-radius: 1rem;
border: 1px solid oklch(0.928 0.006 264);
padding: 1.5rem;
}
@utility input-base {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid oklch(0.88 0.01 270);
border-radius: 0.5rem;
font-size: 0.875rem;
outline: none;
transition: border-color 150ms, box-shadow 150ms;
}
@utility scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
Ключова перевага: повна підтримка варіантів
Це принципова відмінність від @layer components. Клас через @utility є повноправним членом Tailwind-екосистеми:
<!-- @utility btn підтримує ВСІ варіанти — як вбудовані утиліти -->
<!-- Responsive: розмір змінюється за breakpoint -->
<button class="btn md:text-base lg:text-lg">
Адаптивна кнопка
</button>
<!-- dark: — темний режим -->
<button class="btn bg-indigo-600 dark:bg-indigo-500 text-white">
Кнопка з темним режимом
</button>
<!-- group-hover: — реакція через group -->
<div class="group">
<button class="btn group-hover:shadow-lg border border-slate-200">
З'являється тінь при hover батька
</button>
</div>
<!-- hover: + active: + focus-visible: -->
<button class="btn bg-indigo-600 text-white
hover:bg-indigo-700 active:scale-95
focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:outline-none">
Повна інтерактивність
</button>
<!-- Стекання: md:dark:hover: -->
<button class="btn md:dark:hover:bg-indigo-400">
Три умови одночасно
</button>
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Mimicking the @utility declarations in CSS */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
font-weight: 600;
border-radius: 0.75rem;
transition-property: color, background-color, border-color, box-shadow, transform;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.card {
background-color: white;
border-radius: 1rem;
border: 1px solid #e2e8f0; /* slate-200 */
padding: 1.5rem;
}
.input-base {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #cbd5e1; /* slate-300 */
border-radius: 0.5rem;
font-size: 0.875rem;
outline: none;
transition: border-color 150ms, box-shadow 150ms;
}
.input-base:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2);
}
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome, Safari and Opera */
}
</style>
</head>
<body class="p-6 bg-slate-50" style="font-family: 'Inter', system-ui, sans-serif;">
<div class="max-w-2xl mx-auto space-y-6">
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest text-center">@utility Custom Classes Showcase</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Part 1: btn + card + input-base Form -->
<div class="card shadow-sm space-y-4">
<div>
<h3 class="text-sm font-bold text-slate-900">Реєстрація на розсилку</h3>
<p class="text-xs text-slate-500 mt-0.5">Приклад картки з кастомним input та btn</p>
</div>
<div class="space-y-3">
<div>
<label class="block text-xs font-semibold text-slate-600 mb-1">Email адреса</label>
<input type="email" placeholder="you@example.com" class="input-base">
</div>
<button class="btn w-full bg-indigo-600 text-white hover:bg-indigo-700 active:scale-95">
Підписатися
</button>
</div>
</div>
<!-- Part 2: Interactive elements and horizontal scrollbar-hide -->
<div class="space-y-6">
<!-- Buttons with different utility compositions -->
<div class="card shadow-sm space-y-3">
<h4 class="text-xs font-bold text-slate-400 uppercase">Поведінка btn з утилітами</h4>
<div class="flex flex-wrap gap-2">
<!-- btn with scale and background hover -->
<button class="btn bg-indigo-600 text-white hover:bg-indigo-700 active:scale-95 text-xs px-4 py-2">
Атомарні варіанти
</button>
<!-- responsive size btn -->
<button class="btn border border-slate-200 hover:bg-slate-50 text-slate-700 md:text-sm text-xs px-4 py-2">
Адаптивна кнопка
</button>
</div>
</div>
<!-- scrollbar-hide demo -->
<div class="card shadow-sm space-y-3">
<h4 class="text-xs font-bold text-slate-400 uppercase">Scrollbar Hide (Прокрути вбік)</h4>
<div class="scrollbar-hide flex gap-3 overflow-x-auto py-1 snap-x">
<div class="flex-shrink-0 w-24 h-16 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-xl snap-center flex items-center justify-center text-white text-xs font-semibold">Картка 1</div>
<div class="flex-shrink-0 w-24 h-16 bg-gradient-to-br from-pink-500 to-rose-500 rounded-xl snap-center flex items-center justify-center text-white text-xs font-semibold">Картка 2</div>
<div class="flex-shrink-0 w-24 h-16 bg-gradient-to-br from-orange-500 to-amber-500 rounded-xl snap-center flex items-center justify-center text-white text-xs font-semibold">Картка 3</div>
<div class="flex-shrink-0 w-24 h-16 bg-gradient-to-br from-emerald-500 to-teal-500 rounded-xl snap-center flex items-center justify-center text-white text-xs font-semibold">Картка 4</div>
<div class="flex-shrink-0 w-24 h-16 bg-gradient-to-br from-sky-500 to-blue-500 rounded-xl snap-center flex items-center justify-center text-white text-xs font-semibold">Картка 5</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
@apply. Варіанти (hover:, dark:) технічно компілюються, але є антипатерном. Специфічність може несподівано перевизначатись.flex, p-4, text-sm. Правильний сучасний підхід.🧪 Практика: власна утилітарна система
Визначте через @utility три базові компоненти: btn (кнопка з flex та padding), card (картка з border та rounded), badge (мітка з px/py та rounded-full). Потім використайте їх у HTML, поєднуючи з варіантами: hover:card:shadow-lg, dark:badge:bg-slate-700, md:btn:text-base. Переконайтеся, що варіанти працюють коректно.
@layer base — глобальна основа проєкту
Шар base у Tailwind займає найнижчий рівень специфічності і призначений для глобальних стилів, що повинні застосовуватись до всіх елементів проєкту: скидання браузерних стилів, базова типографіка, глобальні CSS Custom Properties:
@layer base {
/* CSS-скидання та загальні правила */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
/* Плавне масштабування тексту на мобільних */
}
body {
@apply font-sans antialiased;
/* antialiased: -webkit-font-smoothing: antialiased */
color: var(--color-text, oklch(0.15 0.02 264));
background-color: var(--color-bg, oklch(1 0 0));
line-height: 1.6;
}
/* Заголовки: базова ієрархія */
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
line-height: 1.2;
@apply text-slate-900 tracking-tight;
}
h1 { @apply text-4xl; }
h2 { @apply text-3xl; }
h3 { @apply text-2xl; }
h4 { @apply text-xl; }
/* Посилання: глобальний стиль */
a {
@apply text-indigo-600 transition-colors;
text-underline-offset: 3px;
}
a:hover { @apply text-indigo-700; }
/* Зображення: не виходять за межі контейнера */
img, video, svg {
max-width: 100%;
display: block;
}
/* Accessibility: focus-visible для клавіатурної навігації */
:focus-visible {
outline: 2px solid oklch(0.58 0.22 277);
outline-offset: 2px;
border-radius: 2px;
}
/* Виділення тексту: брендовий колір */
::selection {
background-color: oklch(0.58 0.22 277 / 0.2);
color: oklch(0.2 0.05 264);
}
}
@layer base конкурує з Tailwind Preflight — вбудованим скиданням стилів, яке автоматично підключається через @import 'tailwindcss'. Preflight базується на modern-normalize і вже скидає більшість браузерних стилів. У @layer base додавайте лише те, чого Preflight не охоплює.@layer components — бібліотека класів-компонентів
Шар components призначений для семантичних класів, що поєднують кілька CSS-властивостей та представляють концептуальний компонент UI. Він розташований між base (нижче) і utilities (вище), що дозволяє утилітарним класам перевизначати компонентні:
@layer components {
/* ─── Badge System ─────────────────────────────────── */
.badge {
@apply inline-flex items-center gap-1
px-2.5 py-0.5
text-xs font-semibold rounded-full;
}
.badge-success { @apply bg-emerald-100 text-emerald-800; }
.badge-warning { @apply bg-amber-100 text-amber-800; }
.badge-error { @apply bg-red-100 text-red-800; }
.badge-info { @apply bg-sky-100 text-sky-800; }
.badge-neutral { @apply bg-slate-100 text-slate-700; }
.badge-purple { @apply bg-violet-100 text-violet-800; }
/* ─── Form System ───────────────────────────────────── */
.form-group { @apply flex flex-col gap-1.5; }
.form-label { @apply text-sm font-semibold text-slate-700; }
.form-hint { @apply text-xs text-slate-400; }
.form-error { @apply text-xs text-red-500 flex items-center gap-1; }
.form-input {
@apply w-full px-3 py-2
border border-slate-300 rounded-lg
text-sm text-slate-900 placeholder:text-slate-400
focus:outline-none focus:border-indigo-500
focus:ring-2 focus:ring-indigo-500/20
transition-colors;
}
.form-input-error {
@apply border-red-400 bg-red-50/50
focus:border-red-500 focus:ring-red-500/20;
}
.form-textarea {
@apply form-input resize-none min-h-24;
}
/* ─── Alert System ──────────────────────────────────── */
.alert {
@apply flex gap-3 p-4 rounded-xl border text-sm;
}
.alert-success {
@apply bg-emerald-50 border-emerald-200 text-emerald-800;
}
.alert-warning {
@apply bg-amber-50 border-amber-200 text-amber-800;
}
.alert-error {
@apply bg-red-50 border-red-200 text-red-800;
}
.alert-info {
@apply bg-sky-50 border-sky-200 text-sky-800;
}
/* ─── Divider ───────────────────────────────────────── */
.divider {
@apply flex items-center gap-3
text-xs text-slate-400 uppercase tracking-widest
before:flex-1 before:h-px before:bg-slate-200
after:flex-1 after:h-px after:bg-slate-200;
}
}
Перевага @layer components: класи-компоненти перевизначаються утилітарними класами через механізм шарування. Якщо вам потрібно для конкретного <input> задати інший border-radius — просто додайте rounded-2xl у class="", і воно переможе .form-input:
<!-- .form-input: rounded-lg -->
<!-- rounded-2xl перевизначає rounded-lg з components шару -->
<input class="form-input rounded-2xl" />
🧪 Практика: форма через @layer components
Визначте у @layer components повну форм-систему: .form-group, .form-label, .form-input, .form-input-error, .form-hint, .form-error. Реалізуйте форму реєстрації з трьома полями (ім'я, email, password), кнопкою submit та divider. Один із inputs — у стані error. Жодних довгих ланцюжків класів у HTML — тільки семантичні класи.
Реальні компоненти: повний demo
Набір компонентів, що демонструє комбінацію всіх підходів: базові стилі через @layer components, інтерактивна поведінка через Tailwind-варіанти у HTML.
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body class="p-8 bg-slate-50 space-y-10" style="font-family: 'Inter', system-ui, sans-serif;">
<!-- Кнопки: повна система варіантів -->
<section>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Button System</p>
<div class="bg-white rounded-2xl border border-slate-200 p-6 space-y-4">
<!-- Primary варіанти -->
<div class="flex flex-wrap gap-3 items-center">
<button class="inline-flex items-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 active:scale-95 text-white font-semibold rounded-xl transition-all text-sm shadow-sm hover:shadow-md">
Primary
</button>
<button class="inline-flex items-center gap-2 px-5 py-2.5 border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50 active:bg-indigo-100 font-semibold rounded-xl transition-all text-sm">
Secondary
</button>
<button class="inline-flex items-center gap-2 px-5 py-2.5 text-slate-600 hover:bg-slate-100 hover:text-slate-900 font-semibold rounded-xl transition-all text-sm">
Ghost
</button>
<button class="inline-flex items-center gap-2 px-5 py-2.5 bg-red-500 hover:bg-red-600 active:bg-red-700 text-white font-semibold rounded-xl transition-all text-sm">
Danger
</button>
<button class="inline-flex items-center gap-2 px-5 py-2.5 bg-indigo-600 text-white font-semibold rounded-xl text-sm opacity-50 cursor-not-allowed" disabled>
Disabled
</button>
</div>
<!-- Розміри -->
<div class="flex flex-wrap gap-3 items-center">
<button class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg transition-all text-xs">
Small
</button>
<button class="inline-flex items-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-all text-sm">
Medium
</button>
<button class="inline-flex items-center gap-2.5 px-7 py-3.5 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-2xl transition-all text-base">
Large
</button>
</div>
</div>
</section>
<!-- Form Fields -->
<section>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Form Fields</p>
<div class="bg-white rounded-2xl border border-slate-200 p-6">
<div class="grid grid-cols-2 gap-6 max-w-lg">
<!-- Normal field -->
<div class="flex flex-col gap-1.5">
<label class="text-sm font-semibold text-slate-700">Ім'я</label>
<input type="text" placeholder="Іван Кравченко"
class="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20
placeholder:text-slate-400 transition-colors">
<p class="text-xs text-slate-400">Ваше повне ім'я</p>
</div>
<!-- Error state -->
<div class="flex flex-col gap-1.5">
<label class="text-sm font-semibold text-slate-700">Email</label>
<input type="email" value="invalid@"
class="w-full px-3 py-2 border border-red-400 rounded-lg text-sm
bg-red-50/50 focus:outline-none focus:border-red-500
focus:ring-2 focus:ring-red-500/20 transition-colors">
<p class="text-xs text-red-500 flex items-center gap-1">⚠ Невалідний формат email</p>
</div>
<!-- Textarea -->
<div class="flex flex-col gap-1.5 col-span-2">
<label class="text-sm font-semibold text-slate-700">Повідомлення</label>
<textarea rows="3" placeholder="Ваше повідомлення..."
class="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20
placeholder:text-slate-400 transition-colors resize-none"></textarea>
</div>
</div>
</div>
</section>
<!-- Badges + Alerts -->
<section class="grid grid-cols-2 gap-6">
<!-- Badges -->
<div>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Badge System</p>
<div class="bg-white rounded-2xl border border-slate-200 p-5 space-y-3">
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 text-xs font-semibold rounded-full bg-emerald-100 text-emerald-800">● Активний</span>
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 text-xs font-semibold rounded-full bg-amber-100 text-amber-800">⚠ На розгляді</span>
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800">✖ Відхилено</span>
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 text-xs font-semibold rounded-full bg-sky-100 text-sky-800">ℹ В роботі</span>
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 text-xs font-semibold rounded-full bg-slate-100 text-slate-700">Чернетка</span>
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 text-xs font-semibold rounded-full bg-violet-100 text-violet-800">🔮 Beta</span>
</div>
</div>
</div>
<!-- Alerts -->
<div>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Alert System</p>
<div class="space-y-2">
<div class="flex gap-3 p-4 bg-emerald-50 border border-emerald-200 rounded-xl">
<span class="text-emerald-500 font-bold flex-shrink-0 text-sm">✓</span>
<div>
<p class="text-sm font-semibold text-emerald-800">Успішно збережено!</p>
<p class="text-xs text-emerald-700 mt-0.5">Зміни опубліковані.</p>
</div>
</div>
<div class="flex gap-3 p-4 bg-red-50 border border-red-200 rounded-xl">
<span class="text-red-500 font-bold flex-shrink-0 text-sm">✖</span>
<div>
<p class="text-sm font-semibold text-red-800">Помилка збереження</p>
<p class="text-xs text-red-700 mt-0.5">Перевірте підключення.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Divider + Dropdown -->
<section>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4">Divider та Select</p>
<div class="bg-white rounded-2xl border border-slate-200 p-6 max-w-sm space-y-4">
<button class="w-full inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl transition-all text-sm">
Увійти через Email
</button>
<div class="flex items-center gap-3 text-xs text-slate-400 uppercase tracking-widest
before:flex-1 before:h-px before:bg-slate-200
after:flex-1 after:h-px after:bg-slate-200">
або
</div>
<button class="w-full inline-flex items-center justify-center gap-2 px-5 py-2.5 border border-slate-300 hover:border-slate-400 hover:bg-slate-50 text-slate-700 font-semibold rounded-xl transition-all text-sm">
Увійти через Google
</button>
</div>
</section>
</body>
</html>
@custom-variant: власні варіанти без JavaScript
Концепція та мотивація
Система варіантів Tailwind (hover:, dark:, md:, focus:) є декларативним відображенням CSS-умов: кожен варіант — це умова, за якої застосовується утилітарний клас. Логічне питання: а чи можна визначати власні умови? До Tailwind v4 відповідь потребувала JavaScript-плагіну:
// v3: JS-плагін — єдиний спосіб додати власний варіант
plugin(function({ addVariant }) {
addVariant('hocus', ['&:hover', '&:focus'])
addVariant('dark', '.dark &')
addVariant('theme-ocean', '.theme-ocean &')
})
Tailwind v4 вирішує це елегантно — через директиву @custom-variant у чистому CSS:
/* v4: @custom-variant — без JS */
@custom-variant hocus (&:hover, &:focus-visible);
@custom-variant dark (&:is(.dark *));
@custom-variant theme-ocean (&:is(.theme-ocean *));
Синтаксис: однорядковий та блоковий
@custom-variant має два варіанти синтаксису:
Однорядковий — для умов на основі CSS-селекторів:
/* @custom-variant {name} ({CSS-умова}); */
/* Поєднання станів: hover або focus */
@custom-variant hocus (&:hover, &:focus-visible);
/* Контекст батьківського класу */
@custom-variant dark (&:is(.dark *));
@custom-variant theme-ocean (&:is(.theme-ocean *));
@custom-variant theme-forest (&:is(.theme-forest *));
/* data-атрибути */
@custom-variant data-active (&[data-active]);
@custom-variant data-loading (&[data-loading]);
@custom-variant data-error (&[data-error="true"]);
/* aria-атрибути */
@custom-variant aria-expanded (&[aria-expanded="true"]);
@custom-variant aria-selected (&[aria-selected="true"]);
Блоковий — для медіазапитів та @supports:
/* @custom-variant {name} { @{media-or-rule} { @slot; } } */
/* Стилі для друку */
@custom-variant print {
@media print {
@slot;
/* @slot — місце підстановки CSS-правила */
}
}
/* Анімації лише для користувачів без обмежень */
@custom-variant motion-ok {
@media (prefers-reduced-motion: no-preference) {
@slot;
}
}
/* Ландшафтна орієнтація */
@custom-variant landscape {
@media (orientation: landscape) {
@slot;
}
}
/* CSS Grid підтримка */
@custom-variant supports-grid {
@supports (display: grid) {
@slot;
}
}
Практичні приклади
Приклад 1: Dark mode через клас .dark на <html>
Стандартна конфігурація Tailwind використовує prefers-color-scheme: dark для dark:. Якщо ви хочете керувати темою програмно — через клас на <html>:
/* main.css */
@import 'tailwindcss';
/* Перевизначити вбудований dark: варіант */
@custom-variant dark (&:is(.dark *));
<html class="dark"> <!-- або без класу для light -->
<body>
<div class="bg-white dark:bg-slate-900 text-slate-900 dark:text-white
transition-colors duration-300">
Автоматична адаптація до теми
</div>
</body>
</html>
// Перемикання теми через JS
document.documentElement.classList.toggle('dark')
Приклад 2: Multi-theme система
@import 'tailwindcss';
@custom-variant theme-ocean (&:is(.theme-ocean *));
@custom-variant theme-forest (&:is(.theme-forest *));
@custom-variant theme-sunset (&:is(.theme-sunset *));
<!-- Зміна теми через клас на html або будь-якому предку -->
<html class="theme-forest">
<div class="bg-slate-50
theme-ocean:bg-cyan-50
theme-forest:bg-emerald-50
theme-sunset:bg-orange-50
text-slate-900
theme-ocean:text-cyan-900
theme-forest:text-emerald-900
theme-sunset:text-amber-900
transition-colors">
Контент змінює тему відповідно до класу на html
</div>
</html>
Приклад 3: hocus — hover або focus-visible
Один із найпопулярніших UX-патернів: елемент має однакову реакцію на мишу та клавіатуру. Без hocus потрібно дублювати класи:
<!-- До: дублювання для hover і focus-visible -->
<a class="text-slate-700 hover:text-indigo-600 focus-visible:text-indigo-600
hover:underline focus-visible:underline
hover:underline-offset-4 focus-visible:underline-offset-4 transition-colors">
Посилання
</a>
<!-- Після: @custom-variant hocus (&:hover, &:focus-visible) -->
<a class="text-slate-700 hocus:text-indigo-600 hocus:underline hocus:underline-offset-4 transition-colors">
Посилання
</a>
Приклад 4: data-* стани компонентів
Найважливіший практичний патерн: замість перемикання CSS-класів через JavaScript — керування через data-атрибути, що відображаються на CSS:
@custom-variant data-active (&[data-active]);
@custom-variant data-loading (&[data-loading]);
@custom-variant data-error (&[data-error]);
<!-- Tab-система: JavaScript лише перемикає data-active -->
<div class="flex p-1 bg-slate-100 rounded-xl gap-1" id="tabs">
<button data-active
class="flex-1 py-2 rounded-lg text-sm font-semibold
text-slate-500 data-active:bg-white data-active:text-indigo-600
data-active:shadow-sm transition-all">
Головна
</button>
<button class="flex-1 py-2 rounded-lg text-sm font-semibold
text-slate-500 data-active:bg-white data-active:text-indigo-600
data-active:shadow-sm transition-all">
Аналітика
</button>
</div>
<!-- Loading state кнопка -->
<button data-loading
class="px-5 py-2.5 bg-indigo-600 text-white font-semibold rounded-xl
data-loading:opacity-70 data-loading:cursor-wait
data-loading:pointer-events-none transition-all">
<span class="data-loading:hidden">Надіслати</span>
<span class="hidden data-loading:inline-flex items-center gap-2">
⟳ Завантаження...
</span>
</button>
<!-- Input з error state -->
<input data-error
class="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm
data-error:border-red-400 data-error:bg-red-50/50
focus:outline-none focus:ring-2
data-error:focus:ring-red-500/20 transition-colors">
Приклад 5: print — стилі для друку
@custom-variant print {
@media print {
@slot;
}
}
<!-- Приховати UI-елементи при друку -->
<nav class="print:hidden">Навігація (не друкується)</nav>
<button class="print:hidden">Підписатись</button>
<!-- Адаптувати розміри для друку -->
<h1 class="text-4xl print:text-2xl">Заголовок</h1>
<p class="text-base print:text-sm print:leading-tight">Основний текст</p>
<!-- Прибрати декоративні ефекти -->
<div class="shadow-2xl print:shadow-none bg-gradient-to-br print:bg-white from-indigo-50">
Контент
</div>
<!-- Показати URL посилань при друку через after: -->
<a class="text-indigo-600 print:text-slate-900 print:after:content-['_(' attr(href) ')']"
href="https://example.com">
Посилання
</a>
<!DOCTYPE html>
<html lang="uk" id="root">
<head>
<meta charset="UTF-8">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="p-6 bg-slate-100 space-y-6" style="font-family: 'Inter', system-ui, sans-serif;">
<p class="text-xs font-bold uppercase tracking-widest text-slate-400 text-center">data-* стани компонентів</p>
<div class="grid grid-cols-2 gap-4">
<!-- Tab система через data-active -->
<div class="bg-white rounded-2xl border border-slate-200 p-5">
<p class="text-xs font-semibold text-slate-500 mb-3">Tabs з data-active</p>
<div class="flex p-1 bg-slate-100 rounded-xl gap-1" id="tabs">
<button onclick="setTab(this)" data-active
class="flex-1 py-2 rounded-lg text-xs font-semibold transition-all
text-slate-500 [&[data-active]]:bg-white [&[data-active]]:text-indigo-600
[&[data-active]]:shadow-sm">
Головна
</button>
<button onclick="setTab(this)"
class="flex-1 py-2 rounded-lg text-xs font-semibold transition-all
text-slate-500 [&[data-active]]:bg-white [&[data-active]]:text-indigo-600
[&[data-active]]:shadow-sm">
Аналітика
</button>
<button onclick="setTab(this)"
class="flex-1 py-2 rounded-lg text-xs font-semibold transition-all
text-slate-500 [&[data-active]]:bg-white [&[data-active]]:text-indigo-600
[&[data-active]]:shadow-sm">
Налаштування
</button>
</div>
</div>
<!-- Loading кнопка через data-loading -->
<div class="bg-white rounded-2xl border border-slate-200 p-5">
<p class="text-xs font-semibold text-slate-500 mb-3">Loading state (натисни)</p>
<button id="loadBtn"
onclick="startLoad()"
class="w-full px-4 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-xl text-sm transition-all
[&[data-loading]]:opacity-70 [&[data-loading]]:cursor-wait [&[data-loading]]:pointer-events-none">
<span class="[&[data-loading]]:hidden" id="loadText">Надіслати форму</span>
<span class="hidden" id="loadSpinner">⟳ Завантаження...</span>
</button>
</div>
<!-- Dark mode перемикач -->
<div class="bg-white rounded-2xl border border-slate-200 p-5">
<p class="text-xs font-semibold text-slate-500 mb-3">Dark mode toggle</p>
<div id="themeBox" class="bg-slate-50 border border-slate-200 rounded-xl p-4 transition-colors">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-slate-900" id="themeTitle">Світла тема</p>
<p class="text-xs text-slate-500" id="themeDesc">Клас .dark відсутній</p>
</div>
<button onclick="toggleDark()"
class="w-10 h-6 rounded-full bg-slate-200 hover:bg-slate-300 transition-colors relative">
<span id="toggle" class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow-sm transition-transform"></span>
</button>
</div>
</div>
</div>
<!-- Input з data-error -->
<div class="bg-white rounded-2xl border border-slate-200 p-5">
<p class="text-xs font-semibold text-slate-500 mb-3">Input validation</p>
<div class="space-y-2">
<input type="email" placeholder="email@example.com"
oninput="validateEmail(this)"
class="w-full px-3 py-2 border rounded-lg text-sm transition-colors
border-slate-300 focus:border-indigo-500
focus:outline-none focus:ring-2 focus:ring-indigo-500/20
[&[data-error]]:border-red-400 [&[data-error]]:bg-red-50/50
[&[data-error]]:focus:ring-red-500/20">
<p id="emailError" class="text-xs text-red-500 hidden">⚠ Невалідний формат email</p>
</div>
</div>
</div>
<script>
function setTab(el) {
document.querySelectorAll('#tabs button').forEach(b => b.removeAttribute('data-active'));
el.setAttribute('data-active', '');
}
function startLoad() {
const btn = document.getElementById('loadBtn');
const text = document.getElementById('loadText');
const spinner = document.getElementById('loadSpinner');
btn.setAttribute('data-loading', '');
text.classList.add('hidden');
spinner.classList.remove('hidden');
setTimeout(() => {
btn.removeAttribute('data-loading');
text.classList.remove('hidden');
spinner.classList.add('hidden');
}, 2000);
}
let dark = false;
function toggleDark() {
dark = !dark;
const box = document.getElementById('themeBox');
const toggle = document.getElementById('toggle');
const title = document.getElementById('themeTitle');
const desc = document.getElementById('themeDesc');
if (dark) {
box.className = 'bg-slate-900 border border-slate-700 rounded-xl p-4 transition-colors';
toggle.style.transform = 'translateX(1rem)';
title.className = 'text-sm font-semibold text-white';
title.textContent = 'Темна тема';
desc.className = 'text-xs text-slate-400';
desc.textContent = 'Клас .dark активний';
} else {
box.className = 'bg-slate-50 border border-slate-200 rounded-xl p-4 transition-colors';
toggle.style.transform = 'translateX(0)';
title.className = 'text-sm font-semibold text-slate-900';
title.textContent = 'Світла тема';
desc.className = 'text-xs text-slate-500';
desc.textContent = 'Клас .dark відсутній';
}
}
function validateEmail(input) {
const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.value);
const error = document.getElementById('emailError');
if (input.value && !isValid) {
input.setAttribute('data-error', '');
error.classList.remove('hidden');
} else {
input.removeAttribute('data-error');
error.classList.add('hidden');
}
}
</script>
</body>
</html>
🧪 Практика: власна тема-система
Реалізуйте повноцінну multi-theme систему через @custom-variant. Визначте три теми: theme-ocean (cyan/teal палітра), theme-forest (emerald/green) та theme-sunset (orange/amber). Кожна тема — власний @custom-variant. Реалізуйте картку продукту, що автоматично змінює кольори фону, акцентів та тексту залежно від активної теми. Додайте кнопки перемикання теми через JS, що ставлять клас на <html>.
@plugin — плагіни Tailwind v4
Офіційні плагіни через @plugin
Tailwind v4 змінює механізм підключення плагінів: замість конфігурації у tailwind.config.js — директива @plugin у CSS:
/* main.css */
@import 'tailwindcss';
/* Офіційні плагіни */
@plugin "@tailwindcss/typography"; /* prose, prose-lg, prose-invert... */
@plugin "@tailwindcss/forms"; /* Покращені стилі форм */
@plugin "@tailwindcss/aspect-ratio"; /* aspect-w-*, aspect-h-* */
# Встановлення плагінів
npm install @tailwindcss/typography @tailwindcss/forms --save-dev
Після підключення @tailwindcss/typography клас prose стає доступним з повним набором варіантів:
<!-- @tailwindcss/typography: стилізація довільного HTML -->
<article class="prose prose-lg prose-indigo dark:prose-invert max-w-prose mx-auto">
<h1>Заголовок статті</h1>
<p>Перший параграф із <strong>жирним</strong> та <a href="#">посиланням</a>.</p>
<blockquote>Цитата з автоматичним стилем</blockquote>
<pre><code>const x = 1; // code block</code></pre>
</article>
JS-плагін: складна логіка
Якщо @custom-variant та @utility не покривають потреби — можна писати JS-плагін:
/* main.css */
@plugin "./plugins/custom-utilities.js";
// plugins/custom-utilities.js
import plugin from 'tailwindcss/plugin'
export default plugin(function ({ addUtilities, addVariant, matchUtilities, theme }) {
// addUtilities: статичні утиліти
addUtilities({
'.scrollbar-hide': {
'-ms-overflow-style': 'none',
'scrollbar-width': 'none',
'&::-webkit-scrollbar': { display: 'none' },
},
'.font-smoothing': {
'-webkit-font-smoothing': 'antialiased',
'-moz-osx-font-smoothing': 'grayscale',
},
'.text-balance': { 'text-wrap': 'balance' },
'.text-pretty': { 'text-wrap': 'pretty' },
})
// matchUtilities: динамічні утиліти з arbitrary values
matchUtilities(
{
'grid-cols-fill': (value) => ({
'grid-template-columns': `repeat(auto-fill, minmax(${value}, 1fr))`,
}),
},
{ values: theme('spacing') }
)
// addVariant: кастомний варіант (краще через @custom-variant у CSS)
addVariant('hocus', ['&:hover', '&:focus-visible'])
})
<!-- Використання JS-плагіну -->
<div class="overflow-x-auto scrollbar-hide">Прокрутка без скролбару</div>
<h1 class="font-smoothing">Згладжений шрифт</h1>
<div class="grid grid-cols-fill-[250px] gap-4">Автоматична сітка</div>
<a class="hocus:text-indigo-600">Hover або focus</a>
| Інструмент | Призначення | Коли використовувати |
|---|---|---|
@utility | Атомарна утиліта з CSS | Власні одно-властивісні класи: scrollbar-hide, font-smoothing |
@layer components + @apply | Семантичний клас-компонент | Повторювані патерни у шаблонах без фреймворку |
@custom-variant | Власна CSS-умова | Нові стани: data-*, aria-*, print, multi-theme |
JS @plugin | Складна логіка | Динамічні утиліти, обчислювані значення, matchUtilities |
| JS компонент | Інкапсуляція | React/Vue/Svelte — завжди перший вибір |
🧪 Практика: власний плагін
Напишіть JS-плагін, що додає три утиліти: (1) .text-balance і .text-pretty як статичні утиліти, (2) .grid-auto-fill-{spacing} як динамічну утиліту через matchUtilities (значення з theme('spacing')), (3) @custom-variant (або addVariant) hocus. Використайте цей плагін у демо-сторінці із сіткою карток, збалансованими заголовками та hocus: посиланнями.
Архітектура CSS файлів: production-ready структура
Для production-проєктів CSS-файли організовуються за принципом відповідальності:
src/styles/
├── main.css ← Точка входу: @import усього
├── theme/
│ ├── colors.css ← @theme { --color-* }
│ ├── fonts.css ← @theme { --font-* } + @font-face
│ └── tokens.css ← @theme { --spacing-*, --radius-*, --shadow-* }
├── base/
│ ├── reset.css ← @layer base { *, html, body }
│ └── globals.css ← @layer base { h*, a, ::selection, :focus-visible }
├── components/
│ ├── buttons.css ← @utility btn + @layer components .btn-*
│ ├── forms.css ← @layer components .form-*
│ ├── cards.css ← @layer components .card*
│ └── badges.css ← @layer components .badge*
├── variants/
│ └── custom.css ← @custom-variant dark, hocus, print...
└── utilities/
└── custom.css ← @utility scrollbar-hide, font-smoothing...
/* main.css — точка входу */
/* 1. Зовнішні шрифти (до @import tailwindcss!) */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
/* 2. Tailwind */
@import 'tailwindcss';
/* 3. Плагіни */
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
/* 4. Дизайн-токени */
@import './theme/colors.css';
@import './theme/fonts.css';
@import './theme/tokens.css';
/* 5. Кастомні варіанти */
@import './variants/custom.css';
/* 6. Base шар */
@import './base/reset.css';
@import './base/globals.css';
/* 7. Компоненти */
@import './components/buttons.css';
@import './components/forms.css';
@import './components/cards.css';
@import './components/badges.css';
/* 8. Кастомні утиліти */
@import './utilities/custom.css';
Ця структура забезпечує:
- Передбачуваний порядок: Tailwind Preflight → base → components → utilities
- Ізоляцію: кожен файл — одна відповідальність
- Масштабованість: нові компоненти — нові файли, без конфліктів
Завдання для самоперевірки
Завдання 1.1. Аналіз повторень.
Відкрийте будь-який HTML-файл із Tailwind-класами. Знайдіть 3 місця, де набори класів повторюються 3+ рази. Виведіть їх у @layer components через @apply. Перевірте: HTML скоротився? Стилі змінились?
Завдання 1.2. Три способи кнопки.
Реалізуйте одну кнопку btn-primary трьома способами:
- Inline Tailwind: повний набір класів у HTML
@applyу@layer components: семантичний клас.btn-primary@utility btn-primary: власна утиліта першого класу
Перевірте: для кожного способу чи працює hover:btn-primary:scale-105? Чи dark:btn-primary? Який підхід підтримує більше варіантів?
Завдання 1.3. @layer base для проєкту.
Реалізуйте @layer base:
- Скидання
box-sizing: border-boxдля всіх елементів body: Inter шрифт,antialiased,text-slate-700,bg-white- Заголовки
h1–h4: правильна ієрархія черезtext-{size} font-bold - Посилання:
text-indigo-600 hover:text-indigo-700 transition-colors :focus-visible:outline-2 outline-indigo-500 outline-offset-2::selection:bg-indigo-500/20 text-indigo-900
Завдання 2.1. Форма реєстрації через семантичні класи.
Реалізуйте форму виключно через класи з @layer components та @utility:
<form class="card max-w-md mx-auto">
<h2 class="form-title">Реєстрація</h2>
<div class="form-group">
<label class="form-label">Email <span class="required-star">*</span></label>
<input class="form-input" type="email" placeholder="email@example.com" />
<p class="form-hint">Буде використано для входу</p>
</div>
<div class="form-group">
<label class="form-label">Пароль</label>
<input class="form-input form-input-error" type="password" />
<p class="form-error">⚠ Пароль закороткий (мінімум 8 символів)</p>
</div>
<div class="divider">або</div>
<button class="btn btn-primary btn-full">Зареєструватись</button>
<button class="btn btn-ghost btn-full mt-2">Увійти через Google</button>
</form>
Визначте всі класи: card, form-title, form-group, form-label, form-input, form-input-error, form-hint, form-error, required-star, divider, btn, btn-primary, btn-ghost, btn-full.
Завдання 2.2. @custom-variant dark mode.
Реалізуйте перемикач теми:
- Визначте
@custom-variant dark (&:is(.dark *))у CSS - Реалізуйте компонент-картку із повною підтримкою
dark::dark:bg-slate-800 dark:border-slate-700 dark:text-white dark:text-slate-300 - Додайте toggle-кнопку: клік перемикає клас
.darkна<html> - Анімація переходу:
transition-colors duration-300на body
Завдання 2.3. Tabs через data-active.
Реалізуйте компонент Tabs:
- 4 таби з
data-activeатрибутом на активному - Стилі через довільний варіант
[&[data-active]]:або@custom-variant data-active - Кожен таб показує відповідний контент-блок
- JavaScript лише перемикає
data-activeатрибут
Завдання 3.1. Повна файлова структура.
Організуйте CSS для mid-size SaaS продукту за наведеною структурою:
src/styles/
├── main.css ← @import усього
├── theme/
│ ├── colors.css ← OKLCH бренд-кольори + семантичні токени
│ ├── fonts.css ← Inter + display шрифт
│ └── tokens.css ← spacing, radius, shadow
├── base/
│ └── globals.css ← body, headings, links, focus, selection
├── components/
│ ├── buttons.css ← @utility btn + 4 варіанти + 3 розміри
│ ├── forms.css ← повна форм-система
│ └── badges.css ← badge + 6 семантичних варіантів
├── variants/
│ └── custom.css ← dark, hocus, print, data-active, data-loading
└── utilities/
└── custom.css ← scrollbar-hide, text-balance, font-smoothing
Вимоги: мінімум 3 повних компоненти, @custom-variant dark + перемикач теми, @utility для 3+ кастомних класів, @layer base з усіма глобальними стилями.
Завдання 3.2. Multi-theme система.
Реалізуйте застосунок із трьома темами (Ocean, Forest, Sunset):
@custom-variantдля кожної теми@themeтокени для кожної:--color-primary-*,--color-bg,--color-surface- Картки і кнопки використовують тільки семантичні токени
- Тема-switcher: кнопки перемикають клас на
<html> - Плавний перехід:
transition-colors duration-500на всіх елементах
Попередня стаття: Типографіка та система кольорівНаступна стаття: Темна тема та система токенів
Типографіка та система кольорів у Tailwind v4
Академічний розбір типографічної та кольорової систем Tailwind CSS v4: OKLCH-палітра, opacity modifiers, font-size шкала, line-height, font-weight, text-balance/pretty, prose плагін та практичні патерни. З живими прикладами та завданнями.
Темна тема та система дизайн-токенів у Tailwind v4
Вичерпний посібник з реалізації темної теми у Tailwind CSS v4: від теорії CSS Custom Properties до production-ready системи токенів. Стратегії перемикання теми, semantic tokens, multi-theming, збереження вибору у localStorage та підтримка системних налаштувань.