Tailwind

Темна тема та система дизайн-токенів у Tailwind v4

Вичерпний посібник з реалізації темної теми у Tailwind CSS v4: від теорії CSS Custom Properties до production-ready системи токенів. Стратегії перемикання теми, semantic tokens, multi-theming, збереження вибору у localStorage та підтримка системних налаштувань.

Темна тема та система дизайн-токенів у Tailwind v4

Вступ: чому темна тема — це не просто «інвертувати кольори»

Темна тема стала стандартною вимогою сучасного веб-інтерфейсу. Однак більшість розробників підходять до її реалізації надто спрощено: додають клас dark: до кожного кольорового класу і вважають роботу завершеною. Такий підхід, хоча й функціональний у малих масштабах, породжує низку серйозних архітектурних проблем у міру зростання проєкту.

Справжня темна тема — це система дизайн-токенів, де кожне кольорове рішення описане семантично, а не конкретним відтінком. Замість того, щоб думати «цей текст slate-900 на світлому, а slate-100 на темному» — ви думаєте «це --color-text-primary, і він автоматично адаптується до активної теми». Один клас у HTML. Нуль dark:-дублікатів.

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

Ця стаття передбачає знайомство з матеріалом попередніх статей, зокрема з директивою @theme (стаття 05) та варіантами (стаття 06). Якщо ви ще не читали їх — рекомендуємо зробити це перед тим, як продовжувати.

Частина І. Теоретична основа

1.1. CSS Custom Properties як рушій динамічних тем

Перш ніж говорити про Tailwind, необхідно розібратися з механізмом, на якому побудована будь-яка сучасна система тем — CSS Custom Properties (або CSS-змінні).

CSS Custom Properties — це змінні, оголошені у CSS та доступні по всьому дереву DOM. На відміну від препроцесорних змінних (Sass, Less), які розгортаються під час компіляції і є статичними, CSS Custom Properties живуть у браузері та можуть змінюватися під час виконання.

Базовий синтаксис:

/* Оголошення — завжди починається з двох дефісів */
:root {
    --color-text: oklch(0.15 0.02 270);
}

/* Використання — через функцію var() */
p {
    color: var(--color-text);
}

Коли ви змінюєте значення --color-text (наприклад, перемикаєте клас на :root), всі елементи, що використовують var(--color-text), миттєво оновлюються — без перезавантаження сторінки і без JavaScript-маніпуляцій зі стилями кожного елемента окремо.

Саме ця властивість CSS Custom Properties робить їх ідеальним інструментом для тематизації: ви змінюєте одну точку — і вся система реагує автоматично.

Успадкування та каскад

CSS Custom Properties підпорядковуються каскаду та успадкуванню CSS так само, як і звичайні властивості. Це означає:

:root {
    --color-accent: oklch(0.585 0.233 277); /* індиго */
}

.card-promo {
    /* Локальне перевизначення — впливає тільки на нащадків .card-promo */
    --color-accent: oklch(0.72 0.19 50); /* помаранчевий */
}
🔒localhost:3000

Елемент всередині .card-promo, що використовує var(--color-accent), отримає помаранчевий колір. Елемент поза .card-promo — індиго. Це фундаментальна поведінка, яку ми пізніше використаємо для multi-theming.


1.2. Семантичні токени проти примітивних токенів

Будь-яка зріла дизайн-система розрізняє два рівні токенів:

Примітивні токени (primitive tokens) — це конкретні значення кольорів, розмірів, відступів. Вони описують «що є», а не «навіщо»:

--color-indigo-500: oklch(0.585 0.233 277.117);
--color-slate-900: oklch(0.129 0.042 264.695);
--color-slate-50: oklch(0.984 0.003 247.858);

Семантичні токени (semantic tokens) — це аліаси, що описують призначення. Вони посилаються на примітивні токени і відповідають на питання «навіщо використовується цей колір»:

--color-text-primary: var(--color-slate-900);   /* основний текст */
--color-text-muted: var(--color-slate-500);     /* другорядний текст */
--color-bg-base: var(--color-white);            /* фон сторінки */
--color-bg-surface: var(--color-slate-50);      /* фон карток */
--color-border: var(--color-slate-200);         /* рамки */
--color-accent: var(--color-indigo-500);        /* акцентний колір */

Коли перемикається темна тема — змінюються лише семантичні токени:

.dark {
    --color-text-primary: var(--color-slate-50);
    --color-text-muted: var(--color-slate-400);
    --color-bg-base: oklch(0.13 0.03 270);
    --color-bg-surface: oklch(0.19 0.03 270);
    --color-border: oklch(0.28 0.02 270);
    --color-accent: var(--color-indigo-400); /* трохи яскравіший для темного фону */
}

Компонент у HTML використовує лише семантичні токени — і ніколи не знає, яка тема активна:

<p class="text-[--color-text-primary]">Це завжди правильний колір</p>
Цей підхід повністю усуває необхідність писати dark:text-slate-100 разом з text-slate-900 для кожного текстового елемента. Один клас із семантичним токеном — і він сам знає, яким бути у будь-якій темі.

1.3. Три стратегії реалізації темної теми

Перед тим як переходити до коду, важливо визначитися зі стратегією. У Tailwind v4 та сучасному CSS існують три підходи:

Стратегія 1: Медіазапит prefers-color-scheme

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

Переваги: повністю автоматично, не потребує JavaScript, найдоступніший підхід.

Недоліки: користувач не може змінити тему на сайті незалежно від системних налаштувань.

Стратегія 2: Клас на кореневому елементі

Темна тема активується додаванням класу .dark (або атрибута data-theme="dark") до елемента <html>. JavaScript перемикає цей клас.

Переваги: повний контроль, легко зберігати у localStorage, підтримка ручного перемикання.

Недоліки: потребує JavaScript для початкового читання localStorage, можливий «спалах» неправильної теми при завантаженні.

Стратегія 3: Гібридний підхід (рекомендований)

Поєднує обидві стратегії: за замовчуванням слухає prefers-color-scheme, але якщо користувач явно обрав тему — зберігає вибір у localStorage та застосовує клас на <html>.

Переваги: найкраща UX: поважає системні налаштування, але дає можливість ручного вибору.

Недоліки: складніша реалізація, але варта зусиль для production.

У цій статті ми реалізуємо гібридний підхід — від простого до складного, пояснюючи кожен крок.


Частина ІІ. Базовий dark: варіант у Tailwind v4

2.1. Як працює dark: у Tailwind

Перш за все, розберемо найпростіший механізм — вбудований варіант dark:.

У Tailwind CSS варіант dark: генерує CSS-правило, що застосовується лише за певної умови. За замовчуванням у Tailwind v4 ця умова — CSS медіазапит:

/* Що генерує Tailwind для класу dark:bg-slate-900 */
@media (prefers-color-scheme: dark) {
    .dark\:bg-slate-900 {
        background-color: oklch(0.129 0.042 264.695);
    }
}

Написавши в HTML:

<body class="bg-white dark:bg-slate-900">

Ви отримуєте елемент, що автоматично змінює фон залежно від системних налаштувань користувача. Жодного JavaScript.

Стекування dark: з іншими варіантами

Варіант dark: вільно комбінується з будь-якими іншими варіантами. Порядок написання — від зовнішнього контексту до конкретної утиліти:

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

<!-- dark + responsive + hover -->
<div class="text-slate-900 md:text-slate-700 dark:text-slate-100 dark:md:text-slate-200">
    Текст
</div>

<!-- dark + group-hover -->
<div class="group">
    <span class="text-slate-600 group-hover:text-indigo-600 dark:text-slate-400 dark:group-hover:text-indigo-400">
        Реакція на hover батька в темній темі
    </span>
</div>
🔒localhost:3000
Важливо розуміти: кожна пара {клас} dark:{клас} подвоює кількість класів у HTML. На великому проєкті це може призвести до надзвичайно довгих рядків класів, що важко читати та підтримувати. Саме тому для серйозних проєктів рекомендується підхід із семантичними токенами, описаний у Частині ІІІ.

2.2. Перемикання на клас: @custom-variant dark

Щоб dark: реагував не на медіазапит, а на CSS-клас (необхідно для ручного перемикання теми), у Tailwind v4 використовується директива @custom-variant.

Додайте у ваш головний CSS-файл одразу після @import 'tailwindcss':

src/styles/main.css
@import 'tailwindcss';

/* Перевизначення поведінки dark: варіанту */
/* Тепер dark: спрацьовує, коли будь-який батьківський елемент має клас .dark */
@custom-variant dark (&:is(.dark *));

Тепер dark:bg-slate-900 генерує:

/* Замість @media — селектор класу */
.dark .dark\:bg-slate-900 {
    background-color: oklch(0.129 0.042 264.695);
}

А HTML виглядає так:

<html class="dark">
    <body>
        <!-- dark: класи тепер активні -->
        <div class="bg-white dark:bg-slate-900">
            Темний фон завдяки .dark на <html>
        </div>
    </body>
</html>

Для перемикання теми — маніпулюємо класом на <html>:

// Увімкнути темну тему
document.documentElement.classList.add('dark')

// Вимкнути темну тему
document.documentElement.classList.remove('dark')

// Перемикати
document.documentElement.classList.toggle('dark')
Використання класу на <html> (а не на <body> чи іншому елементі) є загальноприйнятою практикою. Це дозволяє стилізувати <html> та <body> теж, а також гарантує, що темна тема охоплює абсолютно всі елементи сторінки.

2.3. Живий приклад: базова темна тема через dark:

Подивімося на мінімальний повний приклад — проста картка з підтримкою темної теми через клас:

🔒localhost:3000

Натиснувши кнопку 🌙, ви перемикаєте клас .dark на <html> — і вся картка змінює зовнішній вигляд. Зверніть увагу, скільки dark:-пар у кожному елементі. Для однієї картки це ще прийнятно, але для цілої сторінки — код стає важко підтримуваним.


Частина ІІІ. Система семантичних токенів: правильний підхід

3.1. Архітектура токенів

Зріла система токенів складається з трьох рівнів:

Loading diagram...
graph TD
    A["Рівень 1:<br/>Примітивні токени<br/>(Palette)"] --> B["Рівень 2:<br/>Семантичні токени<br/>(Semantic)"]
    B --> C["Рівень 3:<br/>Компонентні токени<br/>(Component)"]

    A1["--color-indigo-500<br/>--color-slate-900<br/>--color-slate-50"] --> A
    B1["--color-text-primary<br/>--color-bg-base<br/>--color-accent"] --> B
    C1["--btn-bg<br/>--card-border<br/>--input-focus-ring"] --> C

    style A fill:#3b82f6,color:#fff
    style B fill:#f59e0b,color:#fff
    style C fill:#64748b,color:#fff

Рівень 1: Примітивна палітра — всі доступні кольори у всіх відтінках. Це «словник» кольорів системи. Визначається один раз у @theme і не змінюється між темами.

Рівень 2: Семантичні токени — аліаси з описовими іменами (text-primary, bg-surface, border-default). Саме вони змінюються при перемиканні теми.

Рівень 3: Компонентні токени — специфічні для компонентів (--btn-primary-bg, --card-shadow). Опціональний рівень для складних систем.

Для більшості проєктів достатньо перших двох рівнів.


3.2. Визначення примітивної палітри в @theme

Розпочнемо з побудови примітивної палітри. Всі кольори визначаємо у @theme — так Tailwind автоматично генерує для них utility-класи:

src/styles/theme/colors.css
@theme {
    /* ===== ПРИМІТИВНА ПАЛІТРА ===== */

    /* Нейтральна шкала (основа для тексту, фонів, рамок) */
    --color-neutral-50:  oklch(0.984 0.003 247.858);
    --color-neutral-100: oklch(0.961 0.005 264.531);
    --color-neutral-200: oklch(0.929 0.008 264.542);
    --color-neutral-300: oklch(0.872 0.012 264.052);
    --color-neutral-400: oklch(0.704 0.026 264.436);
    --color-neutral-500: oklch(0.554 0.034 264.364);
    --color-neutral-600: oklch(0.446 0.030 264.139);
    --color-neutral-700: oklch(0.373 0.034 259.733);
    --color-neutral-800: oklch(0.279 0.029 256.848);
    --color-neutral-900: oklch(0.208 0.042 265.755);
    --color-neutral-950: oklch(0.130 0.028 261.692);

    /* Акцентний колір: Indigo (бренд) */
    --color-indigo-50:  oklch(0.962 0.018 272.314);
    --color-indigo-100: oklch(0.930 0.034 272.788);
    --color-indigo-200: oklch(0.870 0.065 274.039);
    --color-indigo-300: oklch(0.785 0.115 274.713);
    --color-indigo-400: oklch(0.673 0.182 276.935);
    --color-indigo-500: oklch(0.585 0.233 277.117);
    --color-indigo-600: oklch(0.511 0.262 276.966);
    --color-indigo-700: oklch(0.457 0.240 277.023);
    --color-indigo-800: oklch(0.398 0.195 277.366);
    --color-indigo-900: oklch(0.359 0.144 278.697);
    --color-indigo-950: oklch(0.257 0.090 281.288);

    /* Допоміжні кольори для статусів */
    --color-success-400: oklch(0.748 0.160 145.439);
    --color-success-500: oklch(0.652 0.168 145.497);
    --color-success-600: oklch(0.540 0.145 146.748);
    --color-success-100: oklch(0.962 0.044 156.743);
    --color-success-900: oklch(0.270 0.085 154.724);

    --color-warning-400: oklch(0.854 0.163 91.936);
    --color-warning-500: oklch(0.769 0.188 70.080);
    --color-warning-100: oklch(0.973 0.071 103.193);
    --color-warning-900: oklch(0.476 0.144 67.110);

    --color-error-400: oklch(0.704 0.191 22.182);
    --color-error-500: oklch(0.637 0.237 25.331);
    --color-error-600: oklch(0.577 0.245 27.325);
    --color-error-100: oklch(0.936 0.032 17.717);
    --color-error-900: oklch(0.391 0.155 27.518);
}
Значення OKLCH у наведеному прикладі відповідають тим самим кольорам, що використовує Tailwind за замовчуванням (slate, indigo, emerald, amber, red). Якщо ви хочете повністю власну палітру — визначте свої значення OKLCH. Про те, як підбирати кольори в OKLCH, читайте у статті 07.

3.3. Семантичні токени та перемикання теми

Тепер визначаємо семантичні токени — окремо для світлої та темної теми. Їх не варто класти у @theme, оскільки вони не є частиною дизайн-системи як такої — вони змінюються залежно від контексту:

src/styles/theme/semantic.css
/* ===== СЕМАНТИЧНІ ТОКЕНИ: СВІТЛА ТЕМА (за замовчуванням) ===== */
:root {
    /* --- Фони --- */
    --color-bg-base:     var(--color-neutral-50);   /* Фон сторінки */
    --color-bg-surface:  oklch(1 0 0);               /* Фон карток, панелей */
    --color-bg-elevated: oklch(1 0 0);               /* Фон модальних вікон */
    --color-bg-inset:    var(--color-neutral-100);   /* Фон вкладених елементів */
    --color-bg-muted:    var(--color-neutral-100);   /* Приглушений фон */

    /* --- Текст --- */
    --color-text-primary:   var(--color-neutral-900);  /* Основний текст */
    --color-text-secondary: var(--color-neutral-700);  /* Другорядний текст */
    --color-text-muted:     var(--color-neutral-500);  /* Приглушений текст */
    --color-text-disabled:  var(--color-neutral-400);  /* Неактивний текст */
    --color-text-inverse:   oklch(1 0 0);              /* Текст на темному фоні */

    /* --- Рамки --- */
    --color-border-default:  var(--color-neutral-200); /* Стандартна рамка */
    --color-border-strong:   var(--color-neutral-300); /* Виражена рамка */
    --color-border-focus:    var(--color-indigo-500);  /* Рамка фокусу */

    /* --- Акцент (бренд) --- */
    --color-accent:          var(--color-indigo-500);
    --color-accent-hover:    var(--color-indigo-600);
    --color-accent-light:    var(--color-indigo-50);
    --color-accent-text:     var(--color-indigo-700);

    /* --- Статуси --- */
    --color-status-success-bg:   var(--color-success-100);
    --color-status-success-text: var(--color-success-600);
    --color-status-warning-bg:   var(--color-warning-100);
    --color-status-warning-text: var(--color-warning-900);
    --color-status-error-bg:     var(--color-error-100);
    --color-status-error-text:   var(--color-error-600);
}

/* ===== СЕМАНТИЧНІ ТОКЕНИ: ТЕМНА ТЕМА ===== */
/* Активується при наявності класу .dark на батьківському елементі */
.dark {
    /* --- Фони --- */
    --color-bg-base:     oklch(0.113 0.021 261);   /* Найтемніший — фон сторінки */
    --color-bg-surface:  oklch(0.179 0.027 261);   /* Трохи світліший — картки */
    --color-bg-elevated: oklch(0.225 0.025 264);   /* Ще світліший — модальні */
    --color-bg-inset:    oklch(0.145 0.025 262);   /* Темніший вкладений */
    --color-bg-muted:    oklch(0.152 0.022 262);   /* Приглушений */

    /* --- Текст --- */
    --color-text-primary:   var(--color-neutral-50);   /* Майже білий */
    --color-text-secondary: var(--color-neutral-200);  /* Світло-сірий */
    --color-text-muted:     var(--color-neutral-400);  /* Сірий */
    --color-text-disabled:  var(--color-neutral-600);  /* Темніший сірий */
    --color-text-inverse:   var(--color-neutral-900);  /* Темний текст */

    /* --- Рамки --- */
    --color-border-default:  oklch(0.300 0.025 264);   /* Темна рамка */
    --color-border-strong:   oklch(0.380 0.025 264);   /* Виразніша темна рамка */
    --color-border-focus:    var(--color-indigo-400);  /* Яскравіший фокус */

    /* --- Акцент (бренд) — трохи яскравіший для темного фону --- */
    --color-accent:       var(--color-indigo-400);
    --color-accent-hover: var(--color-indigo-300);
    --color-accent-light: oklch(0.257 0.090 281) / 0.2; /* приглушений indigo-950 */
    --color-accent-text:  var(--color-indigo-300);

    /* --- Статуси --- */
    --color-status-success-bg:   var(--color-success-900);
    --color-status-success-text: var(--color-success-400);
    --color-status-warning-bg:   oklch(0.476 0.144 67) / 0.2;
    --color-status-warning-text: var(--color-warning-400);
    --color-status-error-bg:     oklch(0.391 0.155 27) / 0.2;
    --color-status-error-text:   var(--color-error-400);
}
Зверніть увагу на закономірність: у темній темі текст стає світлим, а фони — темними. Але важливо, що ієрархія зберігається: bg-surface завжди трохи світліший за bg-base, text-secondary завжди менш контрастний за text-primary. Це забезпечує читабельність та візуальну ієрархію незалежно від теми.

3.4. Використання семантичних токенів у Tailwind v4

У Tailwind v4 є два способи використання CSS-змінних у класах.

Спосіб 1: Синтаксис квадратних дужок (arbitrary values):

<div class="bg-[var(--color-bg-surface)] text-[var(--color-text-primary)]">
    ...
</div>

Спосіб 2: Синтаксис круглих дужок з дефісами (новинка Tailwind v4):

<div class="bg-(--color-bg-surface) text-(--color-text-primary)">
    ...
</div>

Другий синтаксис є скороченням, введеним у Tailwind v4. Він коротший та читабельніший. bg-(--color-bg-surface) еквівалентний bg-[var(--color-bg-surface)].

Синтаксис (--var-name) — це справжня новинка Tailwind v4, яка суттєво спрощує роботу з CSS Custom Properties. Варто запам'ятати цей патерн — він знадобиться вам постійно у production-коді.

Відтепер компонент картки, що раніше виглядав так:

<!-- ❌ Підхід dark: — багато класів, важко підтримувати -->
<div class="bg-white dark:bg-slate-900
            text-slate-900 dark:text-slate-50
            border border-slate-200 dark:border-slate-800
            shadow-sm">

Тепер виглядає так:

<!-- ✅ Підхід семантичних токенів — лаконічно та підтримувано -->
<div class="bg-(--color-bg-surface)
            text-(--color-text-primary)
            border border-(--color-border-default)
            shadow-sm">
🔒localhost:3000

Один компонент. Жодних dark:-дублікатів. Тема змінюється автоматично через CSS Custom Properties.


3.5. Реєстрація семантичних токенів у @theme

Якщо ви хочете, щоб семантичні токени також генерували utility-класи Tailwind (наприклад, bg-bg-surface, text-text-primary), зареєструйте їх у @theme:

src/styles/main.css
@import 'tailwindcss';

@theme {
    /* Реєстрація семантичних токенів як Tailwind-утиліт */
    /* Тепер генеруються: bg-bg-base, bg-bg-surface, text-text-primary, тощо */
    --color-bg-base:          initial; /* Tailwind побачить цей токен */
    --color-bg-surface:       initial;
    --color-bg-elevated:      initial;
    --color-bg-inset:         initial;
    --color-text-primary:     initial;
    --color-text-secondary:   initial;
    --color-text-muted:       initial;
    --color-border-default:   initial;
    --color-border-focus:     initial;
    --color-accent:           initial;
    --color-accent-hover:     initial;
    --color-status-success-bg: initial;
}

Але зачекайте — тут є тонкість. @theme з initial лише реєструє назву токену, але не дає йому значення. Справжні значення визначаються у :root та .dark через звичайні CSS-правила (як у попередньому розділі).

Альтернативно, можна взагалі відмовитися від @theme для семантичних токенів і просто використовувати синтаксис bg-(--color-bg-surface) — він працює з будь-якими CSS-змінними без реєстрації в @theme.

На практиці для невеликих команд часто зручніше використовувати bg-(--color-bg-surface) напряму. Для великих команд — реєстрація у @theme дає автодоповнення в IntelliSense: редактор бачить усі токени та підказує їх.

Частина IV. JavaScript для керування темою

4.1. Мінімальний перемикач теми

Перш ніж реалізовувати складну систему, розберемо найпростіший варіант — перемикач без збереження стану. Він підходить для демонстрацій та прототипів:

// theme-toggle.js — мінімальний перемикач
const toggle = () => {
    document.documentElement.classList.toggle('dark')
}
<button onclick="toggle()">Перемкнути тему</button>

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


4.2. Зберігання вибору у localStorage

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

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

src/theme.js
/**
 * Модуль керування темою.
 *
 * Підтримує три стани:
 *   'light'  — примусово світла тема
 *   'dark'   — примусово темна тема
 *   'system' — відповідає системним налаштуванням (за замовчуванням)
 */

const STORAGE_KEY = 'app-theme'
const ROOT = document.documentElement

// --- Читання ---

/**
 * Повертає явний вибір користувача або null, якщо вибору не було.
 * @returns {'light' | 'dark' | null}
 */
function getSavedTheme() {
    return localStorage.getItem(STORAGE_KEY)
}

/**
 * Перевіряє, чи системна тема є темною.
 * @returns {boolean}
 */
function systemPrefersDark() {
    return window.matchMedia('(prefers-color-scheme: dark)').matches
}

/**
 * Визначає, яка тема має бути активна зараз.
 * Явний вибір користувача має пріоритет над системними налаштуваннями.
 * @returns {'light' | 'dark'}
 */
function getEffectiveTheme() {
    const saved = getSavedTheme()
    if (saved === 'light' || saved === 'dark') return saved
    return systemPrefersDark() ? 'dark' : 'light'
}

// --- Застосування ---

/**
 * Застосовує тему до DOM.
 * Клас .dark на <html> вмикає темну тему через @custom-variant dark.
 */
function applyTheme(theme) {
    if (theme === 'dark') {
        ROOT.classList.add('dark')
    } else {
        ROOT.classList.remove('dark')
    }

    // Зберігаємо в data-атрибуті для CSS та JS
    ROOT.setAttribute('data-theme', theme)
}

// --- Збереження ---

/**
 * Встановлює тему вручну та зберігає вибір у localStorage.
 * @param {'light' | 'dark' | 'system'} theme
 */
function setTheme(theme) {
    if (theme === 'system') {
        localStorage.removeItem(STORAGE_KEY)
        applyTheme(systemPrefersDark() ? 'dark' : 'light')
    } else {
        localStorage.setItem(STORAGE_KEY, theme)
        applyTheme(theme)
    }
}

/**
 * Перемикає між світлою та темною темою.
 * Якщо активна темна — переходить на світлу, і навпаки.
 */
function toggleTheme() {
    const current = getEffectiveTheme()
    setTheme(current === 'dark' ? 'light' : 'dark')
}

// --- Ініціалізація ---

/**
 * Ініціалізує систему тем.
 * Має викликатися якомога раніше, бажано в <head>.
 */
function initTheme() {
    applyTheme(getEffectiveTheme())

    // Слухаємо зміну системної теми
    // Якщо користувач не зробив явного вибору — реагуємо автоматично
    window.matchMedia('(prefers-color-scheme: dark)')
        .addEventListener('change', (e) => {
            if (!getSavedTheme()) {
                applyTheme(e.matches ? 'dark' : 'light')
            }
        })
}

// Публічний API
export { initTheme, setTheme, toggleTheme, getEffectiveTheme, getSavedTheme }

Використання у головному файлі:

src/main.js
import { initTheme, toggleTheme } from './theme.js'

// Ініціалізація якомога раніше
initTheme()

// Підключення перемикача
document.getElementById('theme-toggle')?.addEventListener('click', toggleTheme)

4.3. Проблема «спалаху» теми (Flash of Incorrect Theme)

FOIT (Flash of Incorrect Theme) — один із найнеприємніших артефактів при роботі з темами. Він виникає, коли:

  1. Браузер починає відображати сторінку зі стандартними стилями (як правило, світла тема)
  2. Завантажується JavaScript
  3. JavaScript читає localStorage та застосовує темну тему
  4. Відбувається помітний стрибок від світлого до темного

Цей стрибок відбувається лише на долі секунди, але помітний ока та дратує користувачів.

Вирішення: блокуючий скрипт у <head>

Єдиний надійний спосіб уникнути FOIT — виконати синхронний JavaScript у <head> до того, як браузер почне рендерити <body>. Такий скрипт блокує рендеринг, тому він має бути мінімальним та швидким:

index.html
<!DOCTYPE html>
<html lang="uk">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Мій застосунок</title>

    <!--
        КРИТИЧНО: Цей скрипт виконується синхронно, до рендеру body.
        Він не повинен мати defer, async або type="module".
        Розмір: ~200 байт — практично не впливає на продуктивність.
    -->
    <script>
        (function() {
            var saved = localStorage.getItem('app-theme')
            var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
            var isDark = saved === 'dark' || (!saved && prefersDark)
            if (isDark) document.documentElement.classList.add('dark')
        })()
    </script>

    <link rel="stylesheet" href="/src/styles/main.css">
</head>
<body class="bg-(--color-bg-base) text-(--color-text-primary)">
    <!-- Сторінка рендериться вже з правильною темою -->
</body>
</html>
Скрипт навмисно написаний у форматі var та IIFE (Immediately Invoked Function Expression), а не const/let та стрілочних функцій. Це забезпечує сумісність зі старими браузерами, де цей скрипт може виконуватися до будь-яких поліфілів.
У фреймворках на кшталт Nuxt або Next.js є власні механізми для вирішення FOIT. У Next.js це next-themes, у Nuxt — useColorMode з @nuxtjs/color-mode. Якщо ви використовуєте ці фреймворки — зверніться до їхньої документації замість реалізації власного рішення.

4.4. Компонент перемикача теми

Реалізуємо повноцінний UI-компонент перемикача з трьома станами: «Світла», «Темна», «Система»:

🔒localhost:3000

4.5. Слухання системної зміни теми

Важливий нюанс: якщо користувач обрав режим «Система», а потім змінив системну тему (наприклад, о 20:00 система автоматично перейшла у темний режим) — ваш застосунок має відреагувати без перезавантаження.

Для цього використовується MediaQueryList.addEventListener:

// Відповідь на зміну системної теми
window.matchMedia('(prefers-color-scheme: dark)')
    .addEventListener('change', (event) => {
        // Реагуємо тільки якщо користувач не зробив явного вибору
        const saved = localStorage.getItem('app-theme')
        if (!saved) {
            const isDark = event.matches
            document.documentElement.classList.toggle('dark', isDark)
            console.log(`Системна тема змінилась на: ${isDark ? 'темна' : 'світла'}`)
        }
    })
Цей обробник варто додати під час initTheme() і зберегти посилання на нього, якщо вам колись знадобиться його видалити (removeEventListener). На практиці в більшості застосунків він живе протягом усієї сесії — видаляти не потрібно.

Частина V. Складне: Multi-theming та компонентна ізоляція

5.1. Що таке multi-theming і коли він потрібен

Multi-theming (багатотемність) — це здатність застосунку підтримувати більше двох тем одночасно, або застосовувати різні теми до різних секцій сторінки незалежно.

Типові сценарії:

  • SaaS-платформа з можливістю white-labeling: кожен клієнт має власну кольорову схему
  • Маркетинговий сайт із різними кольоровими секціями (hero — темна, features — світла, pricing — brand-color)
  • Компонентна бібліотека де кожен компонент має варіанти кольорів (variant="primary", variant="danger")

Механізм multi-theming заснований на тій самій поведінці CSS Custom Properties, що ми вже розглядали: значення змінних успадковуються від найближчого батька, де вони визначені.


5.2. Теми через data-theme атрибут

Найелегантніший підхід до multi-theming — використання атрибута data-theme:

src/styles/themes/brands.css
/* Тема: Ocean (синьо-бірюзова) */
[data-theme="ocean"] {
    --color-accent:       oklch(0.591 0.221 228); /* cyan-600 */
    --color-accent-hover: oklch(0.520 0.225 232); /* cyan-700 */
    --color-accent-light: oklch(0.951 0.052 240); /* cyan-50 */
    --color-accent-text:  oklch(0.398 0.170 241); /* cyan-800 */
}

/* Тема: Forest (зелена) */
[data-theme="forest"] {
    --color-accent:       oklch(0.527 0.154 150); /* emerald-700 */
    --color-accent-hover: oklch(0.446 0.130 150); /* emerald-800 */
    --color-accent-light: oklch(0.951 0.052 163); /* emerald-50 */
    --color-accent-text:  oklch(0.296 0.066 143); /* emerald-900 */
}

/* Тема: Sunset (помаранчева) */
[data-theme="sunset"] {
    --color-accent:       oklch(0.646 0.222 41);  /* orange-500 */
    --color-accent-hover: oklch(0.553 0.195 38);  /* orange-600 */
    --color-accent-light: oklch(0.980 0.016 73);  /* orange-50 */
    --color-accent-text:  oklch(0.408 0.153 38);  /* orange-800 */
}

/* Тема: Rose (рожева) */
[data-theme="rose"] {
    --color-accent:       oklch(0.645 0.246 16);  /* rose-500 */
    --color-accent-hover: oklch(0.586 0.253 17);  /* rose-600 */
    --color-accent-light: oklch(0.969 0.015 12);  /* rose-50 */
    --color-accent-text:  oklch(0.455 0.188 13);  /* rose-800 */
}

Використання: атрибут data-theme застосовується до будь-якого контейнера:

<!-- Вся сторінка у темі "ocean" -->
<html data-theme="ocean">

<!-- Або тільки одна секція у темі "sunset" -->
<section data-theme="sunset" class="py-24">
    <button class="bg-(--color-accent) hover:bg-(--color-accent-hover) text-white px-6 py-3 rounded-xl">
        Ця кнопка помаранчева
    </button>
</section>

<!-- Рядом інша секція у темі "forest" -->
<section data-theme="forest" class="py-24">
    <button class="bg-(--color-accent) hover:bg-(--color-accent-hover) text-white px-6 py-3 rounded-xl">
        А ця — зелена
    </button>
</section>

Той самий клас bg-(--color-accent) — різні кольори залежно від data-theme батька. Жодних умовних класів у HTML компонента.


5.3. Поєднання dark mode та multi-theming

Що якщо потрібно поєднати довільні бренд-теми з підтримкою темного режиму? Кожна тема має мати варіант для темного режиму:

src/styles/themes/brands.css
/* Ocean — світла */
[data-theme="ocean"] {
    --color-accent:       oklch(0.591 0.221 228);
    --color-accent-light: oklch(0.951 0.052 240);
    --color-accent-text:  oklch(0.398 0.170 241);
}

/* Ocean — темна (через поєднання .dark та [data-theme]) */
.dark [data-theme="ocean"],
[data-theme="ocean"].dark {
    --color-accent:       oklch(0.706 0.165 228); /* яскравіший для темного фону */
    --color-accent-light: oklch(0.257 0.090 232); /* дуже темний cyan для фону */
    --color-accent-text:  oklch(0.823 0.120 235); /* світлий для темного фону */
}
Специфічність CSS-правила .dark [data-theme="ocean"] вища за [data-theme="ocean"], тому при наявності класу .dark темна версія акценту автоматично перемагає.

Живий приклад багатотемного підходу:

🔒localhost:3000

Зверніть: кнопка у всіх трьох картках має ідентичний клас bg-[--color-accent]. Зміна теми відбувається виключно через data-theme на батьківському контейнері — HTML компонентів не торкається.


Частина VI. Структура файлів: production-ready проєкт

6.1. Рекомендована організація CSS

У реальному проєкті система токенів розподіляється по окремих файлах. Це спрощує підтримку: дизайнер або автор теми знає, де саме шукати потрібне значення.


6.2. Повна демонстраційна сторінка

Зберемо все воєдино: повноцінна сторінка зі стійкою системою токенів, перемикачем теми, перемикачем бренду та набором UI-компонентів.

🔒localhost:3000

6.3. Порівняльна таблиця підходів

Підсумуємо три підходи до реалізації темної теми, розглянуті у цій статті:

Критерійdark: класиСемантичні токениMulti-theme
Складність реалізаціїНизькаСередняВисока
Кількість класів у HTMLПодвоєнаЗвичайнаЗвичайна
ПідтримуваністьСлабкаВідміннаВідмінна
Кількість тем22Необмежена
Автодоповнення IDEПовнеПотребує реєстраціїПотребує реєстрації
Підходить дляПрототипів, MVPProductionEnterprise, white-label
Потребує JavaScriptНі (media) / Так (клас)ТакТак
Анти-FOIT скриптРекомендованийОбов'язковийОбов'язковий

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


Підсумок: що ми побудували

Loading diagram...
mindmap
    root((Темна тема<br/>Tailwind v4))
        Теорія
            CSS Custom Properties
            Рівні токенів
            Три стратегії
        Базовий рівень
            dark варіант
            custom-variant dark
            Стекування варіантів
        Токени
            Примітивна палітра
            Семантичні токени
            Синтаксис bg--var-name
        JavaScript
            theme.js модуль
            localStorage
            Anti-FOIT скрипт
            OS change listener
        Multi-theming
            data-theme атрибут
            Бренд-теми
            dark + data-theme
        Архітектура
            Структура файлів
            Production приклад
            WCAG контраст

Ключові принципи, які варто засвоїти з цієї статті:

Токени > Кольори

Ніколи не прив'язуйте UI до конкретного відтінку. text-(--color-text-primary) — завжди правильний вибір. text-slate-900 — лише у примітивній палітрі.

CSS робить роботу

JavaScript перемикає клас або атрибут. Все інше — CSS Custom Properties. Чим менше JS у темній темі — тим краще.

Anti-FOIT обов'язковий

Синхронний скрипт у <head> — не опція, а вимога для production. Без нього користувачі в темній темі побачать миготіння при кожному завантаженні.

Ієрархія успадкування

CSS Custom Properties успадковуються у дереві DOM. data-theme на контейнері — елегантний спосіб ізолювати теми без умовних класів у компонентах.

Попередня стаття: Компоненти та повторюваність: @apply, @utility та патерниНаступна стаття: Довільні значення та контейнерні запити

Copyright © 2026