Порівняння дев’яти технік темного режиму: від хаків з фільтрами до серверних куки
Порівняйте дев’ять способів додавання темного режиму до веб-додатку — від зворотних фільтрів до токенів, функції light-dark() та серверних куки — і дізнайтеся, які проблеми тихо виникають при кожному з них.
Режим темряви зазвичай представляється як вибір між швидким рішенням та належним способом вирішення проблеми, але у продакшн-сайтах використовується щонайменше дев’ять різних технік, кожна з яких вирішує лише частину проблеми. Ті аспекти, які ігноруються технікою, зазвичай проявляються пізніше у вигляді мерехтливих сторінок, пошкоджених фіксованих заголовків чи кольорів, які мовчки відмовляються змінюватися. У цьому посібнику оцінюються ці дев’ять підходів, пояснюється, що в кожному з них правильно, а що ні, а також розглядаються деталі, які ускладнюють навіть ретельну реалізацію: мерехтіння неправильної теми, властивість color-scheme, налаштування трьох станів, переходи, вбудований контент та дизайн палітри.
Наведені нижче твердження щодо поведінки базуються на первинних джерелах: проєктах CSS Working Group, стандарті HTML від WHATWG, машинночитабельних даних browser-compat-data, які використовуються на MDN, даних про статус Baseline та вихідному коді бібліотеки next-themes; усе це перевірялося за допомогою безголового Chromium. Дві поширені думки не витримують такої перевірки, а одна з характеристик — filter, який обробляє нащадків з параметром position: fixed — виявляється набагато кращою причиною уникати триків з інверсією, ніж загальноприйнятий аргумент про продуктивність.
Темний режим — це три окремі проблеми
„Додати темний режим“ звучить як одне завдання. Насправді це об’єднує три окремі питання, настільки незалежні одне від одного, що навіть ідеальна відповідь на одне з них може призвести до появи проблем у фінальному продукті:
- Яку тему слід відображати? У операційній системі є власний вибір, користувач може захотіти його змінити, а також потребує способу повернутися до налаштувань системи. Простий перемикач увімкнення/вимкнення остаточно позбавляє можливості третього варіанту.
- Як змінюються кольори? Один перемикач має оновлювати кожну поверхню, рамку, іконку та тінь, а його розташування фактично визначає архітектуру CSS.
- Коли застосовується тема? Якщо рішення приймається після першого малювання елемента, користувачі бачать, як сторінка змінює колір прямо перед ними.
Кожна з описаних нижче технік відповідає на певну частину цих запитань. Закономірність очевидна: так звані підходи типу „lazy“ зазвичай розглядають друге запитання окремо та повністю ігнорують перше та третє.
Як читати рейтинг
Топ-три варіанти не є конкурентами. Вони поєднуються в одну систему: семантичні токени є основою, light-dark() — це більш компактний спосіб запису цих токенів, а кукі, яку читає сервер, дозволяє передати обраний тематичний режим без використання Flash. Рейтинги з четвертого по шостий — це справжні компроміси, серед яких потрібно обрати один варіант. Рейтинги з сьомого по дев’ятий — це технічний борг.
Рейтинг 9: інверсія всієї сторінки за допомогою filter
Найкоротший можливий темний режим застосовує інверсію та поворот відтінку до кореневого елемента:
html {
filter: invert(1) hue-rotate(180deg);
}
Це негайно змушує діяти друге правило, яке знову інвертує кожен елемент медіа, щоб фото та відео знову виглядали нормально:
/* now patch back everything it broke */
img, video, canvas, svg, [style*="url("] {
filter: invert(1) hue-rotate(180deg);
}
Зазвичай люди висувають заперечення щодо продуктивності, яку важко чітко довести. Існує набагато сильніше заперечення. У документації MDN про блоки-контейнери чітко сказано: якщо параметр filter встановлений на будь-яке значення, крім none, елемент стає контейнером для нащадків із параметрами position: fixed та position: absolute. Крім того, це створює новий контекст накладання, що тихо змінює спосіб обчислення значень z-index для всіх елементів, що знаходяться під ним.
Безголовний тест демонструє це на практиці. Розмістіть фіксовану смугу всередину фільтрованого обгортка на сторінці заввишки 3000 пкс у Chromium 141, прокрутіть вниз на 400 пкс та виміряйте положення смуги:
await p.evaluate(() => window.scrollTo(0, 400));
// -> { "fixed_viewportTop": 100, "abs_viewportTop": 100 }
// A truly viewport-fixed element reports top: 0 after any scroll.
Індикатор відображає зсув вікна перегляду 100 замість 0, тому він більше не є фіксованим; він прокручується разом із контентом. Застосовуючи цей фільтр до елемента html, він руйнує всі фіксовані заголовки, навігацію, модальні вікна, попередження та інші елементи на сторінці. Це дефект коректності, який можна відтворити за кілька рядків коду, а не справа смаку.
Решта проблем є знайомими:
- Кожен растровий елемент потребує зворотної інверсії, а логотипи з вбудованими кольорами бренду все одно виходять неправильними, оскільки поворот відтінку на 180 градусів не є точною зворотною інверсією в жодному кольоровому просторі.
- Кольори бренду перетворюються на свої математичні протилежності замість запроєктованої темної палітри.
filter: grayscale(50%) для фотографій та invert(100%) лише для монохромних іконок.Місця 8 та 7: підходи, які працюють, доки не перестануть
Перевизначення окремих компонентів
Природною першою спробою є створення темної версії для кожного компонента. Це не стільки неправильно, скільки необмежено. Спочатку використовуються світлі стилі:
.card { background: #fff; color: #14161a; }
.card .btn { background: #f0f2f5; }
а потім додаються темні аналоги під класом body:
body.dark .card { background: #121212; color: #fff; }
body.dark .card .btn { background: #333; }
body.dark .card .btn:hover { background: #444; }
Оскільки правила для темного режиму мають переважати над правилами для світлого, селектори стають все більш специфічними; body.dark .card .btn:hover вже містить чотири елементи з самого початку. Хеш-значення також змінюються: #121212 у одному файлі, #111 — у іншому, а #0f0f0f — у скопійованому компоненті, і немає централізованого способу автоматично перевіряти контраст. Основна проблема масштабування може бути описана одним реченням: кількість перевизначень зростає разом із кількістю компонентів, тоді як кількість токенів зростає разом із кількістю ролей, причому більшість систем дизайну мають приблизно від 12 до 20 ролей.
Два окремі таблиці стилів
Завантаження таблиці стилів для світлого та темного режиму за допомогою атрибутів media здається ефективним:
<link rel="stylesheet" href="light.css" media="(prefers-color-scheme: light)">
<link rel="stylesheet" href="dark.css" media="(prefers-color-scheme: dark)">
Цей підхід не усуває друге завантаження, і саме тут люди роблять помилки. Як пояснюється у статті web.dev про prefers-color-scheme, стилевий лист, чия умова media query не відповідає, все одно завантажується, але з найнижчим пріоритетом, тож не може конкурувати з ресурсами, які потрібні сторінці наразі. Перевагою є скорочення критичного шляху, а не зменшення кількості байтів. Крім того, атрибут media лише читає налаштування ОС, тому жодні ручні зміни не можуть на нього вплинути; ці два файли з часом починають відрізнятися; а інструменти для об’єднання коду можуть неправильно їх обробляти, як описано у проблемі Vite.
Місце 6: чисто-CSS перемикач за допомогою :has()
Візуально прихований прапорець та його підпис можуть виконувати функцію перемикача:
<input type="checkbox" id="theme" class="sr-only">
<label for="theme">Dark mode</label>
Кореневий елемент потім реагує на стан поле для позначки через :has():
html:has(#theme:checked) {
color-scheme: dark;
--bg-surface: #1b1f27;
--text-1: #e8e6e3;
--border: #2b313c;
}
Для цього справді не потрібен JavaScript, а :has() досяг рівня „широко доступного“ 19.06.2026 року. Важливі два моменти. По-перше, потрібно змінювати значення кольорів, як і color-scheme, як це робить приклад. По-друге, стан зберігається лише в DOM, тож з кожним завантаженням сторінки все починається з нуля, і нічого не зберігається. Він також не може чітко представити три стани; для цього знадобляться радіо-кнопки та більше гілок селекторів. Це підходить для демонстрації, CodePen чи односторінкового документа, але не для продукту.
Ранг 5: варіант dark: у Tailwind
Варіант dark: не є неправильним, просто він розміщує рішення щодо кольорів у неправильному місці – у шаблонах, які повторюються на кожному місці використання.
<div class="bg-white dark:bg-zinc-900
text-zinc-900 dark:text-zinc-100
border-zinc-200 dark:border-zinc-800
hover:bg-zinc-50 dark:hover:bg-zinc-800">
Результатом є довгі рядки класів, які ускладнюють керування, причому жоден скрипт не може їх перевірити, оскільки темні кольори розкидані по шаблонам. Крім того, існують додаткові теми з високим контрастом чи спеціальні версії інтерфейсу, які лише множать кількість маркапу, замість того щоб додати ще один рівень елементів. Це також не впливає на інтерфейс, створений браузером, і все одно потрібен окремий скрипт для уникнення спалахів.
Рішення знаходиться безпосередньо в самому Tailwind. Підключіть кольори тем до змінних CSS та змініть їх лише один раз; після цього можна використовувати bg-surface без будь-якого префікса dark:. Почніть із стандартного імпорту:
@import "tailwindcss";
Потім визначте власну версію елемента, вкажіть кольори тем на відповідні змінні та присвойте кожній темі свої значення змінних:
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));@theme {
--color-surface: var(--surface);
--color-content: var(--content);
}:root { --surface: #f4f5f7; --content: #14161a; color-scheme: light; }
[data-theme="dark"] { --surface: #1b1f27; --content: #e8e6e3; color-scheme: dark; }
[data-theme="hc"] { --surface: #000000; --content: #ffffff; color-scheme: dark; }
Використання обгортки :where() є навмисним. Воно не надає темній версії жодної додаткової специфічності, тому функції dark: ніколи випадково не перевизначають стилі, які до них не належать. Додавання теми з високим контрастом коштує одного рядка, замість необхідності обробки кожного шаблону.
Ранг 4: дотримання лише параметра prefers-color-scheme
Найпростішим та надійним варіантом є визначення світлих значень за замовчуванням та їх перевизначення всередині запиту до медіа-змінних:
:root {
color-scheme: light;
--bg-base: #ffffff; --text-1: #14161a;
}
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
--bg-base: #12141a; --text-1: #e8e6e3;
}
}
Тут немає JavaScript, немає анімацій та нічого, що потребувало б ініціалізації. Це найшвидший підхід у всьому списку, а його єдиний недолік має вирішальне значення для додатків, але не має значення для контенту: користувачі не можуть змінити системні налаштування.
Якщо ніхто ніколи не просив функції перемикання кольорової схеми, що є звичайним для блогів, документації, журналів змін та маркетингових сторінок, припиніть читання тут. Для таких сайтів ніщо в цьому списку не є кращим.
Є два деталі специфікації, які варто знати:
- Media Queries Level 5 попереджає, що у майбутньому ця функція може отримати більше значень — прикладом є сепія, — і рекомендує тестувати її за допомогою заперечення:
(prefers-color-scheme: dark)проти(not (prefers-color-scheme: dark)), замість прямого відповідненняlight. - Значення
no-preferenceбуло видалено. Воно відсутнє у поточній специфікації, і жоден браузер його не реалізує; користувач без власних уподобань відповідаєlight.
Місце 3: light-dark() та його спосіб беззвучної невдачі
light-dark() приблизно вдвічі зменшує розмір файлу токенів, оскільки одна декларація містить обидві значення:
:root { color-scheme: light dark; } /* REQUIRED */
.card {
background: #fff; /* fallback for old browsers */
background: light-dark(#fff, #1b1f27);
color: light-dark(#14161a, #e8e6e3);
border-color: light-dark(#e2e5ea, #2b313c);
}
/* a manual override becomes ONE property write */
[data-theme="dark"] { color-scheme: dark; }
[data-theme="light"] { color-scheme: light; }
Це повна система тем без жодних блоків @media чи других правил :root. Для ручного перевизначення достатньо лише встановити color-scheme у кореневому елементі.
Однак це коштує командам годин роботи. Тестування кількох варіантів у Chromium 141 із примусовим використанням різних схем кольорів дало три важливі результати:
- Без
color-schemeфункціяlight-dark()не діє. У темній системі вона мовчки повертає світлі кольори, і в консолі немає жодних попереджень про проблему. Це найпоширеніша помилкаlight-dark(), яка залишається непоміченою, доки хтось у темній системі про неї не повідомить.
color-scheme: dark для елемента змушує його використовувати другий аргумент, незалежно від налаштувань операційної системи. Саме тому ручне увімкнення/вимкнення полягає у зміні однієї властивості, а не у заміні класу та додаванні окремих правил.color-scheme: dark для елемента, що не є кореневим, не надає йому темного фону. Воно змінює кольори системи та вбудовані елементи керування, але фон canvas слідує лише налаштуванням кореневого елемента. Це найпоширеніше непорозуміння щодо цієї властивості.Як перевірку вашої власної палітри, у темному режимі Chromium обчислює системний колір Canvas як rgb(18, 18, 18), що відповідає #121212 — тому самому базовому кольору, який Material рекомендує для темних тем.
Перш ніж покладатися на це, врахуйте наступні зауваження:
- Це базовий рівень „ново доступний“, а не „широко доступний“: Chrome та Edge 123, Firefox 120 та Safari 17.5; дата появи у статусі „ново доступний“ — 2024-05-13, тож поріг „широко доступного“ становить приблизно 2026-11-13.
- Він не працює коректно у разі проблем. Браузери без підтримки відхиляють усю декларацію як недійсну, тому завжди спочатку слід використовувати звичайний фолбек для тієї самої властивості, як це показано у прикладі.
- Це значення кольору, тому його не можна використовувати як умову для запитів до медіа. Його використання всередині декларацій у блоку
@mediaє допустимим; це вже інша справа. - Аргументи для зображень, як-от
light-dark(url(a.png), url(b.png)), з’явилися лише у Chrome 150, Firefox 150 та Safari 27 згідно з даними сумісності на момент написання, які є занадто свіжими, щоб на них покладатися.
Одне попередження щодо документації: на сторінці опису light-dark() у MDN вказано Chrome 119 та Safari 17.2, тоді як власні дані MDN browser-compat-data та Baseline API показують 123 та 17.5. Коли ці дані суперечать одне одному, слід довіряти структурованим даним та перевіряти актуальні сторінки перед наведенням версій.
Ранг 2: семантичні токени – основа для всього іншого
Ключове правило – називати роль, яку виконує колір, а не сам колір. Почніть з примітивних значень палітри, які компоненти ніколи не використовують безпосередньо:
/* primitives: raw values, never consumed by components */
:root {
--gray-0: #ffffff; --gray-50: #f4f5f7; --gray-200: #e2e5ea;
--gray-600: #55606e; --gray-900: #14161a; --gray-950: #12141a;
--blue-500: #3b82f6; --blue-400: #60a5fa;
}
Над ними знаходиться семантичний шар. Значення світлих кольорів є стандартними; один блок [data-theme="dark"] замінює їх усі, а компоненти використовують лише назви ролей, тому їм ніколи не потрібно знати, яка тема активна:
/* semantic roles: light is the default */
:root {
color-scheme: light;
--bg-base: var(--gray-0);
--bg-surface: var(--gray-50);
--text-1: var(--gray-900);
--text-2: var(--gray-600);
--border: var(--gray-200);
--accent: var(--blue-500);
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.08);
}/* one block flips the whole app */
[data-theme="dark"] {
color-scheme: dark;
--bg-base: #12141a; /* grey, not #000 */
--bg-surface: #1b1f27; /* lighter = higher up */
--bg-raised: #232833; /* lighter still */
--text-1: #e8e6e3;
--text-2: #a2acbb;
--border: #2b313c;
--accent: var(--blue-400);
--shadow-sm: 0 1px 2px rgb(0 0 0 / 0.5);
}/* components never know which theme is active */
.card { background: var(--bg-surface); color: var(--text-1); border: 1px solid var(--border); }
Зверніть увагу на деталі в темному блоку: основа має темно-сірий колір, а не чорний; поверхні стають світлішими у міру підйому вгору; акцент зміщується на світліший рівень, а тінь стає сильнішою, щоб залишатися видимою.
Простий тест допоможе з’ясувати, чи є назва токена хорошою: чи можете ви описати, коли її слід використовувати, не згадуючи кольору? „Фон піднятого панелі“ описує роль; „світло-сірий“ — зразок кольору. Лише ролі залишаються при зміні теми, адже токен, названий буквально „світло-сірим“, ніколи не повинен стати темним.
Щодо самого перемикача, атрибут data-theme є кращим варіантом, ніж клас .dark. Він природно підтримує три або більше значень, не може конфліктувати з класами для додаткових функцій, а його зміна полягає у присвоєнні значення document.documentElement.dataset.theme. Щоб дізнатися більше про використання користувацьких властивостей у режимі роботи, перегляньте посібник на блогу створення тем за допомогою CSS-користувацьких властивостей.
Місце 1: читання куки з темою на сервері
Відображення теми на сервері — це єдиний варіант, який дозволяє уникнути всіх чотирьох типових проблем: вбудованого скрипту, миттєвого відображення контенту, неузгодженості під час гідратації та винятків у політиці безпеки контенту, оскільки сервер знає тему ще до надсилання першого байту. У проекті Next.js App Router кореневий лейаут імпортує допоміжний модуль для роботи з куками:
// app/layout.tsx
import { cookies } from 'next/headers';
і записує збережену тему безпосередньо у елемент html, при цьому якщо це неможливо, використовується світла тема:
export default async function RootLayout({ children }) {
const store = await cookies(); // async since Next 15
const theme = store.get('theme')?.value ?? 'light'; return (
<html lang="en" data-theme={theme} style={{ colorScheme: theme }}>
<body>{children}</body>
</html>
);
}
Зміна теми відбувається під час дії на сервері, для чого потрібен той самий імпорт:
// app/actions.ts
'use server';
import { cookies } from 'next/headers';
Ця дія зберігає вибір теми на рік, що діє для всього сайту:
export async function setTheme(theme: 'light' | 'dark') {
const store = await cookies();
store.set('theme', theme, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' });
}
Витрати описані самим Next.js:
cookies()— це API, яке працює під час запиту, тому його виклик у лейауті чи сторінці змушує маршрут переходити на динамічне відображення, що позбавляє можливості статичного попереднього відображення.- При увімкнених компонентах кешування виклик
cookies()поза межами<Suspense>також заважає попередньому відображенню. - HTTP не дозволяє створювати куки після початку стрімінгу, тому куку необхідно записати за допомогою
.setу функції сервера чи обробнику маршруту, а не під час відображення.
system все одно має бути здійснений на клієнті. Практичним поєднанням є використання кук для явних виборів та matchMedia у випадку системних налаштувань.Існує клієнтський показник Sec-CH-Prefers-Color-Scheme, який міг би передати переваги ОС на сервер. Це лише проєкт від WICG, доступний лише в Chromium, і не є стандартом, тому розглядайте його хіба що як оптимізацію, а не як загальне рішення.
Запобігання відображенню неправильної теми
Це вже третя проблема з самого початку, а також найпоширеніша помилка темного режиму на випущених сайтах. Дискусії про „правильний підхід проти лінивого підходу“ зазвичай взагалі не стосуються її. Часова шкала показує, чому виконання скрипта з відкладеною обробкою відбувається занадто пізно:
DEFERRED SCRIPT: [HTML][CSS][PAINT: LIGHT][JS][REPAINT: DARK] <- user sees it
BLOCKING INLINE: [HTML][JS][CSS][PAINT: DARK] <- correct first paint
Тему потрібно налаштувати у елементі html ще до першого малювання екрана. Рішенням є невеликий вбудований скрипт у розділі head, розміщений перед таблицею стилів:
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<script>
// inline. no src, no defer, no async, no type=module.
(function () {
try {
var s = localStorage.getItem('theme'); // 'light'|'dark'|'system'|null
var dark = s === 'dark' ||
((!s || s === 'system') &&
matchMedia('(prefers-color-scheme: dark)').matches);
var el = document.documentElement;
el.dataset.theme = dark ? 'dark' : 'light';
el.style.colorScheme = dark ? 'dark' : 'light';
} catch (e) { /* storage throws in private mode / sandboxed iframes */ }
})();
</script>
<link rel="stylesheet" href="/app.css">
</head>
Кожна умова в цьому фрагменті має значення. Вона повинна бути вбудованою та синхронною, без параметрів src, defer, async чи типу модуля, адже будь-який з цих елементів дозволить браузеру спочатку намалювати вміст. Вона читає значення з трьома можливими параметрами та визначає system за допомогою matchMedia. Вона встановлює як атрибут, так і colorScheme, щоб токени та інтерфейс браузера були узгоджені. Крім того, доступ до зберігання даних обгорнутий у блоки try/catch, оскільки localStorage генерує помилки у режимі приватного перегляду та в інфрахвилях зі санкчуарним режимом.
Справжня ціна полягає у політиці безпеки: вбудований скрипт вимагає параметра 'unsafe-inline' або унікального значення nonce у вашій CSP. Якщо ваша політика не дозволяє жодного з цих варіантів, слід використовувати підхід з куками.
У Next.js вам також потрібен suppressHydrationWarning на елементі html, оскільки скрипт змінює свої атрибути до того, як React виконає процес гідратації, і вони більше не збігаються з маркапом сервера. Як зазначено у документації next-themes, цей флаг діє лише на одному рівні, тож він не приховує попереджень про гідратацію в інших місцях.
Читаючи код next-themes, можна побачити, наскільки мало зусиль вкладено у запобігання проблемам із швидким відображенням контенту. Він генерує елемент <script dangerouslySetInnerHTML>, вмістом якого є його власна функція script(), перетворена на рядок за допомогою script.toString(), яка відразу ж викликається з аргументами, серіалізованими у форматі JSON. Хук useTheme() повертає значення theme, setTheme, resolvedTheme, systemTheme та themes, причому під час серверної обробки значення theme дорівнює undefined. Відображайте свій перемикач, який базується на resolvedTheme, після перевірки наявності елемента, інакше сам кнопка перемикача спричинить помилку гідратації.
color-scheme: властивість, яку майже жодні сайти не налаштовують
Ваш стильовий файл визначає колір того, що ви написали, але сам браузер малює полі прокрутки, вбудовані елементи керування формою та полотно за сторінкою. Специфікація CSS Color Adjustment вимагає, щоб пристрій користувача узгоджував усе це зі схемою кольорів елемента:
- стандартні кольори полів прокрутки та інтерактивного інтерфейсу
- стандартний вигляд елементів керування формою
- додаткові елементи інтерфейсу браузера, наприклад підкреслення для перевірки орфографії
- системні кольори, такі як
Canvas,CanvasText,ButtonFace,FieldтаAccentColor - результат виконання функції
light-dark()
У кореневому елементі схема також керує кольором поверхні canvas та полічками прокрутки вікна перегляду. Її потрібно оголосити у трьох місцях. По-перше, у тегу meta, який парсер HTML бачить ще до того, як надійде будь-який CSS:
<!-- parsed at HTML-parse time, BEFORE any CSS loads -->
<meta name="color-scheme" content="light dark">
По-друге, у правилах CSS, які підтримують синхронність цього властивості з атрибутом теми:
:root { color-scheme: light dark; }
[data-theme="dark"] { color-scheme: dark; }
[data-theme="light"]{ color-scheme: light; }
По-третє, за потреби, у механізмі блокування, який зберігає світлий вигляд певного елемента незалежно від теми:
/* force a widget to stay light regardless */
.brand-widget { color-scheme: only light; }
Мета-тег не є зайвим. Розділ стандарту HTML про мета-теги кольорової схеми існує саме для того, щоб браузер міг негайно намалювати фон сторінки у відповідній схемі, не чекаючи на таблиці стилів. Властивість CSS відома лише після того, як таблиця стилів була завантажена та проаналізована; ця перерва призводить до миттєвого білого фона. Стандарт також дозволяє мати не більше одного такого мета-елемента на документ.
Ще дві проблеми:
- Властивість та запит до медіа-типу не пов’язані між собою. Оголошення
color-scheme: darkніколи не змушуєprefers-color-scheme: darkвідповідати, тому код, який намагається вивести одне з цих значень з іншого, буде працювати некоректно.
only повідомляє браузеру, що він не може змінити схему елемента. Насправді саме цей механізм запобігає тому, що Chrome на Android застосовує свою автоматичну темну тему. Його історія сумісності дивна: він був доданий у Chrome 81, видалений у 85 та повернутий у 98.Три стани замість булевого значення
Як тільки параметр стає булевим, опція „слідувати за ОС“ зникає, і користувач не може її повернути. Для цього параметра потрібні три значення: світла, темна та системна. Почніть з ключа для зберігання та списку запитів до медіа:
const STORAGE_KEY = 'theme';
const mq = matchMedia('(prefers-color-scheme: dark)');
Решта логіки полягає у застосуванні вибраного параметра, його збереженні, читанні з значенням system як стандартним, а також у продовженні відстеження змін ОС лише тоді, коли обрано system:
function apply(pref) { // 'light' | 'dark' | 'system'
const dark = pref === 'dark' || (pref === 'system' && mq.matches);
const el = document.documentElement;
el.dataset.theme = dark ? 'dark' : 'light';
el.style.colorScheme = dark ? 'dark' : 'light';
}function setPreference(pref) {
try { localStorage.setItem(STORAGE_KEY, pref); } catch (e) {}
apply(pref);
}function getPreference() {
try { return localStorage.getItem(STORAGE_KEY) || 'system'; }
catch (e) { return 'system'; }
}// keep following the OS, but ONLY while 'system' is the chosen preference
mq.addEventListener('change', () => {
if (getPreference() === 'system') apply('system');
});apply(getPreference());
Використовуйте addEventListener для MediaQueryList, а не addListener. MediaQueryList тепер успадковується від EventTarget, і методи addListener та removeListener є застарілими, хоча багато прикладів у Інтернеті все ще їх використовують. next-themes навмисно зберігає ці застарілі методи, про що зазначено у коментарях до коду, для підтримки старіших версій Safari.
Керування має здійснюватися за допомогою групи з трьох кнопок типу «радіо», а не поле обрання, оскільки три стани вимагають трьох елементів введення.
Зупинка розмивання кольору під час зміни
Якщо елементи на сторінці мають переходи кольору, зміна тем одночасно анімує сотні властивостей, і сторінка помітно розмивається. Рішення, яке використовує next-themes у своєму параметрі disableAnimation, вставляє тимчасовий стиль, який вимикає всі переходи:
function disableTransitionsTemporarily(nonce) {
const css = document.createElement('style');
if (nonce) css.setAttribute('nonce', nonce);
css.appendChild(document.createTextNode(
`*,*::before,*::after{ transition: none !important }`
));
document.head.appendChild(css);
Він повертає функцію, яка відновлює переходи, а допоміжна функція swapTheme обгортає зміну тем між цими двома процесами:
return () => {
// Deliberate forced synchronous reflow: commit the new colours
// WHILE transitions are still off.
(() => window.getComputedStyle(document.body))();
// Remove on a later task, after the flush has committed.
setTimeout(() => { document.head.removeChild(css); }, 1);
};
}function swapTheme(next) {
const enable = disableTransitionsTemporarily();
apply(next);
enable();
}
Виклик getComputedStyle виглядає як „мертвий“ код і часто видаляється. Це навмисний примусовий синхронний оновлення стилю, яке зафіксує нові кольори поки переходи ще вимкнені. Подальше видалення цього коду виконується пізніше за допомогою setTimeout, після того, як оновлення стилю вступить у силу. Без цього примусового оновлення та відкладеного видалення все одно спостерігається часткове затемнення.
Якщо ви хочете, щоб зміна мала видимий ефект, API View Transitions підтримує знайомий круговий ефект розкриття. Він став частиною Baseline з 14 жовтня 2025 року та доступний у Chrome 111, Safari 18 та Firefox 144. Є одна вимога, яку легко проігнорувати: потрібно вимкнути стандартні анімації у кореневих знімках та встановити режим змішування у старому знімку:
::view-transition-old(root) { animation: none; mix-blend-mode: normal; }
::view-transition-new(root) { animation: none; }
Також враховуйте користувачів, які просили менше руху:
@media (prefers-reduced-motion) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation: none !important; }
}
Не видаляйте значення mix-blend-mode: normal зі старого знімка. За замовчуванням використовується режим змішування plus-lighter, через що екран на короткий час стає молочно-білим під час переходу від темного до світлого кольору, що виглядає як баг із спалахом та так і фіксується.
Що все ще не працює після зміни токенів
Зображення та SVG
Елемент <picture> з параметром media="(prefers-color-scheme: dark)" дотримується лише налаштувань операційної системи. Він не може бачити ваш атрибут теми чи параметр color-scheme, тому ручна зміна цього параметра призводить до того, що зображення не синхронізуються з рештою інтерфейсу — це поширена вада. Зображення на тлі, оголошені всередині блоку для темної теми, дійсно підлаштовуються під зміну параметра. Для SVG параметр currentColor працює у вбудованих елементах <svg> та у конструкціях <svg><use href="…">, але не для SVG, завантажених через <img src> чи CSS-функцію url(), оскільки це окремі документи, які ніколи не успадковують ваш параметр color. Тож або вкладіть запити prefers-color-scheme безпосередньо у сам файл SVG, або використовуйте параметр mask-image разом із background-color: currentColor.
Iframes
У специфікаціях налаштування кольору зазначено, що коли схема кольорів iframe відрізняється від схеми кольорів кореневого елемента вбудованого документа, браузер повинен створювати непрозору картинку у елементі Canvas вбудованого документа замість прозорої. На практиці це призводить до появи білого прямокутника на темній сторінці. Вказання атрибута color-scheme для елемента <iframe> виправляє цю проблему з картинкою, проте власний CSS документа іншого походження залишається недоступним. Сервіси YouTube, Stripe Elements, Disqus та Turnstile пропонують окремі опції тем; жодного рішення на рівні CSS не існує.
theme-color
Підтримка мета-атрибута theme-color значно слабкіша, ніж очікують багато хто. Firefox не підтримує його на жодній платформі; десктопна версія Chrome з версії 73 застосовує його лише до встановлених PWA; Safari прийняв його у версії 15, але починаючи з Safari 26 враховує його лише у встановлених веб-додатках. MDN вказує на обмежену доступність цього атрибута, а не на його статус базової підтримки. Специфікація також дозволяє браузерам змінювати колір на свій розсуд, наприклад темнити його для збереження належного контрасту, тому не варто покладатися на точне відтворення.
Примусові кольори та налаштування контрасту
У режимі високого контрасту Windows (forced-colors: active) браузер контролює такі властивості, як background-color, color, border-color, outline-color, text-decoration-color, а також SVG-властивості fill та stroke. Властивості box-shadow та text-shadow скидаються до значення none, а color-scheme фіксується як light dark. Усі ефекти, що залежать від тіней, зникають, тому їх слід замінити на краї кольорами системи:
@media (forced-colors: active) {
/* your shadow-based elevation is gone — replace it */
.card { border: 1px solid CanvasText; box-shadow: none; }
.btn { border: 1px solid ButtonText; }
}
Колір системи, який отримує елемент, залежить від його нативної семантики HTML, а не від ролі ARIA, тому елементу <div role="button"> не надається властивість ButtonText. MDN наполягає на тому, що не варто створювати окремий дизайн для режиму примусових кольорів, достатньо лише внести незначні корективи для кращої читабельності.
Ще один вимір часто зовсім ігнорується: prefers-contrast, який має значення no-preference, more, less та custom. Функція Baseline є широко доступною з 31.05.2022 та не залежить від схеми кольорів. Темні шаблони, які ніколи не враховують значення more, є поширеною проблемою з доступністю.
Створення темної палітри
Використовуйте темно-сірий колір, а не чорний
У посібниках Material рекомендується використовувати темно-сірий колір замість чорного для темних фонів та поверхонь, оскільки сірий колір зберігає видимість тіней та зменшує напругу на очі під час читання тексту світлим кольором. У кодовому лабораторії Google для темних шаблонів зазначено, що чистий текст кольору #FFFFFF на темному фоні може здаватися розмитим чи „вібруючим“, що ускладнює читання.
Скажіть це обережно. Часто повторювана думка про те, що чистий чорний кольор спричиняє ефект „halation“, не підтверджується жодним контрольованим дослідженням. Підтверджується лише ефект просвічування та вібрації від чисто білого тексту, а вибір сірого замість чорного обґрунтовується видимістю тіней та зменшенням напруги на очі.
Виражайте висоту за допомогою світлості
Тіні погано працюють у темних темах, тому Material компенсує це, роблячи поверхні світлішими та трохи кольоровішими у міру їхнього підняття. Функція color-mix() дозволяє легко отримати цей ефект від однієї базової кольорової комбінації:
[data-theme="dark"] {
--surface-1: #12141a;
--surface-2: color-mix(in oklab, var(--surface-1) 92%, white);
--surface-3: color-mix(in oklab, var(--surface-1) 84%, white);
--surface-4: color-mix(in oklab, var(--surface-1) 76%, white);
}
color-mix() є широко доступним згідно з стандартом Baseline з 2025-11-09. Зверніть увагу, що система elevation-overlay у Material Design 2 вже застаріла: у документації Google зазначено, що ці елементи були замінені на систему кольорів поверхні та більше не підтримуються. У Material 3 використовуються ролі від surfaceContainerLowest до surfaceContainerHighest, а також surfaceDim та surfaceBright. Посилання, яке наводить стару таблицю з 5 відсотків при розмірі 1dp до 16 відсотків при розмірі 24dp, посилається на застарілу систему.
Зневоднення акцентних кольорів
Насичені акценти середнього відтінку вібрують на темних поверхнях. OKLCH робить ці корективи систематичними: його канал світлості є перцептивно однорідним, тож кроки з числово рівними значеннями виглядають також однорідними, що саме є недоліком формату HSL — через це темні градієнти у HSL стають брудними посередині. Світліший, менш хроматичний акцент для темної теми виглядає ось так:
:root { --accent: oklch(0.55 0.18 255); } /* darker tone on light bg */
[data-theme="dark"] { --accent: oklch(0.72 0.14 255); } /* lighter, less chroma */
Перевірте контраст знову для темної теми
Палітра, яка відповідає вимогам контрасту у світлому режимі, нічого не говорить про режим темряви. Формула відносної яскравості WCAG 2.x достатньо коротка, щоб її можна було зберегти у скрипті:
const lum = ([r, g, b]) => {
const f = v => (v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
};
Потім співвідношення контрасту ділить світлішу яскравість на темнішу, з кожною з них враховуючи зсув у 0.05:
const contrast = (a, b) => {
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
Виконання цього тесту для кожного кольору, який використовується для читання тексту, дозволяє виявити проблеми, які можуть залишитися непоміченими. Уявіть тему з теплим паперовим кольором, де все виглядало гаразд на екрані: кольору янтарного мав співвідношення 2,06:1, червоний кольор, використовуваний для підсвічування ключових слів, — 2,80:1, а золотий кольор для чисел — 2,50:1. Усі три кольори були схвалені вручну, проте жоден з них не відповідає стандарту AA.
У формулі WCAG 2.x є відома слабкість: вона однаково ставиться до кольору світлий на темному та темний на світлому, тож темна палітра може мати гарні показники, але все одно здаватися яскравою. Саме через цю слабкість була розроблена APCA, проте APCA не є частиною WCAG 2.2 та поки що не є стандартом ніде.
Що кажуть дослідження про темний режим та читабельність
Цей момент часто формулюється неправильно. У звіті з підсумками досліджень від Nielsen Norman Group зазначається:
- Для людей із нормальним зором у дослідженнях Piepenbrock та співавт. (2013, Ergonomics) та Dobres та співавт. (2017, Applied Ergonomics) режим світлого фону показав кращі результати за всіма показниками. Перевага зростає зі зменшенням розміру шрифту. Пояснення полягає у оптичних особливостях: темний текст на світлому фоні випромінює більше світла, зіниця скорочується, а менша зіниця означає менше сферичних аберацій та більшу глибину різкості.
- Для людей із поганим зором Legge та співавт. (1985, Vision Research) виявили, що усі сім учасників із запаленням окулярних структур, зокрема катарактою, читали швидше у режимі темного фону.
- У довгостроковій перспективі Aleman та співавт. (2018, Scientific Reports) припускають, що постійний вплив режиму світлого фону може бути пов’язаний із міопією через потовщення хоріоїди.
- Практична рекомендація полягає у тому, щоб дозволити користувачам переходити у режим темного фону, якщо вони цього бажають.
Чесний підхід полягає у тому, що режим темряви слугує особистим уподобанням та певним потребам у доступності; він не покращує читабельність для широкої аудиторії. Це найсильніший аргумент на користь контролю у трьох станах замість того, щоб встановлювати режим темряви за замовчуванням.
Ключові висновки
- Розглядайте режим темряви як три окремі проблеми: вибір теми, зміна кольорів та застосування вибору перед першим малюванням елементів.
- Називайте токени відповідно до їхніх ролей, змінюйте їх у одному блоку
data-theme, а також підтримуйте синхронність значеньcolor-schemeна кореневому рівні за допомогою мета-тегу. - Вирішуйте питання теми ще до першого малювання — або за допомогою куки, яку читає сервер, або за допомогою вбудованого скрипта, і прийміть компроміси, пов’язані з політикою безпеки контенту, які несе цей скрипт.
- Пропонуйте три стани: світлий, темний та системний, і слідкуйте за змінами операційної системи лише тоді, коли обраний стан „системний“.
prefers-color-scheme замість використання шару токенів є найшвидшим та найпростішим рішенням.filter: invert() на сторінці, якою ви керуєте: це перетворює кореневий елемент на контейнер для кожного фіксованого елемента.Пов’язана література
- Довжина рядків, масштаби відступів, темні поверхні, тіні та кільця фокусу в CSS — Дізнайтеся про основи CSS, які лежать в основі витончених інтерфейсів: довжина рядків на основі ch, масштаб відступів 4px, багатошарові темні поверхні, багатошарові тіні та кільця фокусу.
- Таймери зворотного відліку без відхилень у React: від setTimeout до чистого CSS — Порівняння методів setTimeout, requestAnimationFrame та техніки CSS без використання JavaScript для створення таймерів зворотного відліку у React, включаючи прийом єдиного затримувача для синхронізації цифр.
- Безпечне використання сучасного CSS: Anchors, Grid Lanes, Scope та light-dark() — Дізнайтеся, як позиціонування через anchor, елементи popover, схема Grid Lanes, переходи між виглядами, функції light-dark() та @scope замінюють бібліотеки JavaScript, а також як впровадити кожен з цих інструментів із альтернативами.
- Звідки беруться значення CSS, коли ви встановлюєте «none»: як працює успадкування — Дізнайтеся, як браузери вирішують, чи буде властивість успадковуватися, чому дочірні елементи отримують обчислене значення батьківського елемента, та як параметри inherit та initial надають вам прямий контроль.