Компоненти та повторюваність: @apply, @utility та патерни
Компоненти та повторюваність: @apply, @utility та патерни
Проблема, яка виникає у кожного
Щойно ви освоїли Tailwind і починаєте будувати реальний проєкт — з'являється питання:
У мене є кнопка: 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 text-white. Вона використовується 40 разів. Якщо дизайнер скаже змінити rounded-xl на rounded-2xl — мені оновлювати 40 місць?
Це реальна проблема. І Tailwind має кілька рішень — але кожне підходить для свого контексту.
Рішення 0: Компоненти (найкраще у фреймворках)
Якщо ви використовуєте React, Vue, Svelte, Angular — перше і краще рішення: компонент.
// React: кнопка — це компонент, а не CSS
function Button({ children, variant = 'primary', ...props }) {
const variants = {
primary: 'bg-indigo-600 hover:bg-indigo-700 text-white',
secondary: 'border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50',
ghost: 'text-slate-600 hover:bg-slate-100',
}
return (
<button
className={`inline-flex items-center justify-center gap-2 px-5 py-2.5 font-semibold rounded-xl transition-colors ${variants[variant]}`}
{...props}
>
{children}
</button>
)
}
// Використання — без повторення класів
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
Один компонент — одне місце для зміни. @apply не потрібний. Ось чому Tailwind так популярний саме в React/Vue-екосистемі.
Рішення 1: @apply — перенести класи у CSS
Якщо ви не використовуєте компонентні фреймворки — @apply дозволяє витягти набір Tailwind-класів у CSS-правило:
/* components.css */
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2
px-5 py-2.5 font-semibold rounded-xl transition-colors;
}
.btn-primary {
@apply bg-indigo-600 hover:bg-indigo-700 text-white;
}
.btn-secondary {
@apply border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50;
}
}
<!-- Тепер HTML чистий -->
<button class="btn btn-primary">Primary</button>
<button class="btn btn-secondary">Secondary</button>
Обмеження @apply
@apply не підтримує варіанти повністю. Запис @apply hover:bg-indigo-700 — компілюється, але рекомендується уникати. Краще виносити тільки базові стилі:
/* Добре: базові стилі в @apply */
.card {
@apply bg-white rounded-xl border border-slate-200 shadow-sm p-6;
}
/* Уникайте: варіанти в @apply — краще в HTML */
/* hover:, focus:, dark:, responsive: → залишайте у class="" */
Коли НЕ варто @apply
@apply — це компроміс. Ви повертаєтесь до "CSS-файлу з класами", але тепер вони написані через Tailwind-синтаксис. Всі переваги Tailwind (читабельність у HTML, локальність стилів) частково зникають.Використовуйте @apply тільки для реально повторюваних базових патернів: кнопки, inputs, картки. Не застосовуйте до unікальних секцій сторінки.Рішення 2: @utility — кастомний utility-клас (НОВА v4)
@utility — нова директива Tailwind v4, що дозволяє визначити власний utility-клас зі всіма можливостями варіантів (hover:, dark:, responsive:, group-hover: тощо).
/* Ваш utility-клас, що поводиться як вбудований Tailwind-клас */
@utility 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;
transition-duration: 150ms;
}
@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;
}
Ключова різниця від @apply: з @utility варіанти працюють:
<!-- @utility btn підтримує всі варіанти: hover:, dark:, group-hover:, md: -->
<button class="btn hover:scale-[1.02] bg-indigo-600 text-white dark:bg-indigo-500">
<button class="btn group-hover:shadow-lg border border-slate-300"></button>
</button>
З @layer components (старий підхід) варіанти з кастомними класами не завжди поводяться коректно. @utility — правильний moderne підхід.
@layer base — глобальні стилі
@layer base — найнижчий рівень: скидання браузерних стилів та глобальні налаштування:
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
}
body {
@apply font-sans antialiased text-slate-700 bg-white;
line-height: 1.6;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 700;
line-height: 1.2;
@apply text-slate-900;
}
a {
@apply text-indigo-600 hover:text-indigo-700 transition-colors;
}
img {
max-width: 100%;
display: block;
}
/* Focus visible — доступність */
:focus-visible {
outline: 2px solid oklch(0.6 0.22 270);
outline-offset: 2px;
}
}
@layer components — компонентні класи
Для класів, що поєднують несколько стилів і представляють концептуальні компоненти:
@layer components {
/* Badge */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full;
}
/* Semantic badge variants */
.badge-success {
@apply bg-emerald-100 text-emerald-700;
}
.badge-warning {
@apply bg-amber-100 text-amber-700;
}
.badge-error {
@apply bg-red-100 text-red-700;
}
.badge-info {
@apply bg-sky-100 text-sky-700;
}
/* Input combo: label + input + hint */
.form-group {
@apply flex flex-col gap-1.5;
}
.form-label {
@apply text-sm font-semibold text-slate-700;
}
.form-input {
@apply 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
transition-colors;
}
.form-hint {
@apply text-xs text-slate-400;
}
.form-error {
@apply text-xs text-red-500;
}
/* 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, @utility та inline Tailwind:
<div class="p-6 space-y-8 bg-white" style="font-family: system-ui, sans-serif;">
<!-- Кнопки: різні варіанти -->
<section>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Button System</p>
<div class="flex flex-wrap gap-3 items-center">
<!-- Primary -->
<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"
>
<span>Зберегти</span>
</button>
<!-- Secondary -->
<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 font-semibold rounded-xl transition-colors text-sm"
>
Скасувати
</button>
<!-- Ghost -->
<button
class="inline-flex items-center gap-2 px-4 py-2.5 text-slate-600 hover:bg-slate-100 font-semibold rounded-xl transition-colors text-sm"
>
Додатково
</button>
<!-- Danger -->
<button
class="inline-flex items-center gap-2 px-5 py-2.5 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-xl transition-colors text-sm"
>
Видалити
</button>
<!-- Disabled -->
<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
>
Вимкнено
</button>
</div>
</section>
<!-- Input group -->
<section>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Form Fields</p>
<div class="grid grid-cols-2 gap-4 max-w-lg">
<!-- Normal -->
<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 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-email"
aria-invalid="true"
class="w-full px-3 py-2 border border-red-400 rounded-lg text-sm focus:outline-none focus:border-red-500 focus:ring-2 focus:ring-red-500/20 bg-red-50/50 transition-colors"
/>
<p class="text-xs text-red-500">⚠ Невалідний формат email</p>
</div>
</div>
</section>
<!-- Badges -->
<section>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Badges</p>
<div class="flex flex-wrap gap-2">
<span
class="inline-flex items-center px-2.5 py-0.5 text-xs font-bold rounded-full bg-emerald-100 text-emerald-700"
>● Активний</span
>
<span
class="inline-flex items-center px-2.5 py-0.5 text-xs font-bold rounded-full bg-amber-100 text-amber-700"
>⚠ На розгляді</span
>
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-bold rounded-full bg-red-100 text-red-700"
>✖ Відхилено</span
>
<span class="inline-flex items-center px-2.5 py-0.5 text-xs font-bold rounded-full bg-sky-100 text-sky-700"
>ℹ В роботі</span
>
<span
class="inline-flex items-center px-2.5 py-0.5 text-xs font-bold rounded-full bg-slate-100 text-slate-600"
>Чернетка</span
>
</div>
</section>
<!-- Alert components -->
<section>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Alerts</p>
<div class="space-y-2 max-w-lg">
<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">✓</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-amber-50 border border-amber-200 rounded-xl">
<span class="text-amber-500 font-bold flex-shrink-0">⚠</span>
<div>
<p class="text-sm font-semibold text-amber-800">Зверніть увагу</p>
<p class="text-xs text-amber-700 mt-0.5">Деякі поля не заповнені. Перевірте форму.</p>
</div>
</div>
</div>
</section>
<!-- Dropdown/Select -->
<section>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mb-3">Select</p>
<select
class="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 transition-colors bg-white cursor-pointer appearance-none pr-8"
>
<option>Опція 1</option>
<option>Опція 2</option>
<option>Опція 3</option>
</select>
</section>
</div>
@plugin — власний плагін (v4)
Для повторюваної логіки на рівні всього проєкту — плагін:
/* main.css */
@import 'tailwindcss';
@plugin "./plugins/forms.js";
// plugins/forms.js
import plugin from 'tailwindcss/plugin'
export default plugin(function ({ addComponents, addUtilities, theme }) {
// Компоненти
addComponents({
'.card': {
backgroundColor: 'white',
borderRadius: theme('borderRadius.xl'),
border: `1px solid ${theme('colors.slate.200')}`,
boxShadow: theme('boxShadow.sm'),
padding: theme('spacing.6'),
},
})
// Утиліти з варіантами
addUtilities({
'.scrollbar-hide': {
'-ms-overflow-style': 'none',
'scrollbar-width': 'none',
'&::-webkit-scrollbar': { display: 'none' },
},
})
})
Завдання для самоперевірки
Завдання 1.1. Знайдіть 5 місць у вашому HTML, де однакові набори Tailwind-класів повторюються 3+ рази. Виведіть їх у @layer components через @apply.
Завдання 1.2. Порівняйте підходи. Напишіть кнопку трьома способами:
- Inline Tailwind class
@applyу CSS@utilityу CSS
Поясніть переваги та недоліки кожного.
Завдання 1.3. Реалізуйте @layer base для проєкту: reset, body font, heading hierarchy, link styles, focus-visible для доступності.
Завдання 2.1. Побудуйте повну форму через @layer components:
<form class="card max-w-md">
<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" />
<p class="form-hint">Буде використано для входу</p>
</div>
<div class="divider">або</div>
<button class="btn btn-primary btn-full">Зареєструватись</button>
<button class="btn btn-ghost btn-full">Увійти через Google</button>
</form>
Реалізуйте всі класи через @layer components та @utility.
Завдання 3.1. Design System File Structure.
Організуйте CSS-файли для mid-size SaaS проєкту:
src/styles/
├── main.css ← точка входу
├── theme/
│ ├── colors.css ← @theme { кольори }
│ ├── fonts.css ← @theme { шрифти }
│ └── tokens.css ← @theme { spacing, radius, shadow }
├── base/
│ ├── reset.css ← @layer base { reset }
│ └── globals.css ← @layer base { body, h*, a }
├── components/
│ ├── buttons.css ← @utility btn, @layer components .btn-*
│ ├── forms.css ← @layer components .form-*
│ ├── cards.css
│ └── badges.css
└── utilities/
└── custom.css ← @utility scrollbar-hide, etc.
Реалізуйте мінімум 3 компоненти в цій структурі з документацією використання.
Попередня стаття: Типографіка та система кольорівНаступна стаття: Темна тема та система токенів
Типографіка та система кольорів у Tailwind v4
Повна типографічна система Tailwind v4: шрифти, масштаб, line-height, prose плагін. Кольорова палітра OKLCH, opacity modifiers, кастомні кольори та дизайн-токени. Text-wrap balance/pretty та контент-утиліти.
Showcase Компонентів kostyl.dev
Тут ви можете побачити всі розроблені компоненти для проєкту. Всі вони підтримують macOS-стилістику та адаптивні до світлої/темної тем.