Темний режим, який не підводить: prefers-color-scheme + патерн ручного перемикача

Було корисно?

Темний режим — це просто, доки раптом не стає складно. Першого разу, коли ваш застосунок вилупить білий екран о 2:00 на OLED‑телефоні, ви відчуєте це в душі. Другий раз ще гірше: хтось відкриє баг, бо ваш «темний режим» робить графіки нечитаємими, скидається після кожного оновлення сторінки або ламає друк. Якщо ви поставляєте продакшн‑системи, баги з темою — це не косметика. Це втрата довіри.

Мета тут — патерн, що поводиться як добре керована служба: він поважає платформу, дає явний оверрайд користувачу, зберігає стан передбачувано, уникає мерехтіння і залишається тестованим. Ви реалізуєте prefers-color-scheme правильно, додасте ручний перемикач і збережете все це в підтримуваному вигляді, коли ваша дизайн‑система «виросте зуби».

Як виглядає «добре» у продакшені

Система тем — це не модний показ. Це контракт між браузером, ОС, вашими CSS, JavaScript і намірами користувача.

Без компромісів

  • За замовчуванням: слідувати prefers-color-scheme.
  • Оверрайд: ручний перемикач, що переважає системні налаштування.
  • Збереження: запам’ятовувати оверрайд між сесіями, не застрягаючи в минулому.
  • Без мерехтіння: перший рендер має бути вже у потрібній темі.
  • Доступно: контраст проходить, фокус‑кільця видимі, а перемикач доступний з клавіатури та для екранних рідерів.
  • Компонованість: працює всередині дизайн‑системи та між мікрофронтендами без трьох конкурентних «джерел істини».

Найпоширеніший режим відмови — розглядати «темний режим» як додаток після фактуму в CSS. Це перетворюється на купу перевизначень і невеликий скрипт, що перемикає клас після завантаження. Це не фіча; це стан гонки з інтерфейсом користувача.

Цитата, щоб не забувати: Надія — не стратегія. — Джин Кренц. (Так, її вбивали цитатами до смерті. І все одно вона влучна.)

Короткий жарт №1: Якщо ваш перемикач теми потребує спіннера, ви не побудували просто перемикач — ви побудували розподілену систему.

Факти та історія, які варто знати

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

  1. Ранні «темні UI» були про фосфор і відблиски: термінальні інтерфейси й ранні монітори тягнули світлий текст на темному фоні частково щоб зменшити сприйманий відблиск у темних умовах.
  2. OLED змінив економіку: на OLED чорні пікселі споживають значно менше енергії, ніж білі. На LCD домінує підсвітка, тож економія може бути незначною.
  3. prefers-color-scheme — відносно новий примітив платформи: він став широко доступним лише коли сучасні браузери узгодили підтримку медіа‑запитів; до того кожен сайт винаходив свій власний перемикач і логіку збереження.
  4. Дизайн‑системи ускладнили проблему: коли у вас є токени, компоненти й кілька продуктів, підхід «просто перевизначити кілька кольорів» перестає масштабуватися.
  5. Режими високого контрасту існували значно раніше за хайп довкола теми: примусові кольори ОС та налаштування контрасту вже були; багато запусків «темної теми» випадково ламали їх.
  6. Друк — прихований стейкхолдер: темний фон може бути нормальним на екрані, але катастрофою на папері або в PDF‑експорті, якщо ви не обробили стилі для друку окремо.
  7. Графіки та візуалізації часто страждають: палітри, лінії сітки та контраст підписів потребують окремого налаштування; неможливо просто інвертувати сторінку.
  8. Корпоративні додатки люблять iframe: вбудовані контексти (webview, iframe) ускладнюють передачу теми та збереження налаштувань.

Основний контракт: системні налаштування + оверрайд користувача

Модель, що працює: розглядайте тему як преференцію з трьома станами, а не як булеву змінну.

  • System (за замовчуванням): слідувати prefers-color-scheme.
  • Light (оверайд): користувач примушує світлу тему.
  • Dark (оверайд): користувач примушує темну тему.

Якщо ви зберігаєте просто «dark=true/false», ви неминуче зламаєте когось: користувача, який одного разу переключив тему місяці тому, потім змінив системні налаштування, і тепер дивується, чому ваш застосунок не синхронізується. Три стани вирішують цю проблему. Зберігайте theme=system|light|dark і обчислюйте ефективну тему під час виконання.

Де зберігати

Є два поширені місця для збереження:

  • localStorage: найпростіше, клієнтська сторона, для кожного браузерного профілю. Підходить для більшості сайтів.
  • Cookie: потрібен, якщо ви хочете, щоб SSR віддав правильну тему в першому відповіді без очікування JS.

Виберіть одне. Не пишіть у обидва, якщо вам не подобається відлагоджувати тонкі пріоритетні баги о 3 ранку.

Як застосовувати

Використовуйте один авторитетний атрибут на <html> (або <body>) типу data-theme="dark". Уникайте розкидати класи тем по коренях компонентів. Кожна додаткова точка перемикання — потенційний split‑brain.

CSS‑архітектура, що не розвалиться пізніше

Не робіть тему шляхом переписування стилів компонентів один за одним. Ви помрете від паперових порізів. Темуйте через токени: CSS‑змінні, що відображають намір (surface, text, border, accent), а не сирі кольори.

Використовуйте семантичні токени, а не «blue-500»

Коли продакт‑менеджер попросить «трохи тепліший фон у темній темі», ви хочете змінити одну змінну, а не шукати двадцять hex‑значень. Практичний набір токенів виглядає так:

  • --color-bg, --color-surface
  • --color-text, --color-muted
  • --color-border
  • --color-link, --color-link-visited
  • --color-focus
  • --shadow-elevation-1 (так, і тіньові токени)

Базовий CSS‑патерн

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

cr0x@server:~$ cat theme.css
:root {
  color-scheme: light dark;
  --color-bg: #ffffff;
  --color-surface: #f6f7f9;
  --color-text: #111827;
  --color-muted: #4b5563;
  --color-border: #d1d5db;
  --color-focus: #2563eb;
}

:root[data-theme="dark"] {
  --color-bg: #0b1220;
  --color-surface: #0f172a;
  --color-text: #e5e7eb;
  --color-muted: #94a3b8;
  --color-border: #243041;
  --color-focus: #60a5fa;
}

@media (prefers-color-scheme: dark) {
  :root[data-theme="system"] {
    --color-bg: #0b1220;
    --color-surface: #0f172a;
    --color-text: #e5e7eb;
    --color-muted: #94a3b8;
    --color-border: #243041;
    --color-focus: #60a5fa;
  }
}

html, body {
  background: var(--color-bg);
  color: var(--color-text);
}

Що ви отримуєте: одне місце для визначення значень теми і чистий, тестований механізм вибору. Зверніть увагу на color-scheme: light dark;. Це каже браузеру, що ви підтримуєте обидві теми, щоб елементи форм і смуги прокрутки могли рендеритись відповідно в багатьох середовищах.

Не робіть тему інверсією

CSS‑фільтри типу filter: invert(1) — трюк для вечірок. Вони ламають зображення, руйнують кольори бренду і роблять скриншоти схожими на докази паранормальної діяльності.

Обробляйте зображення та іконки явно

Для іконок віддавайте перевагу SVG з fill="currentColor", щоб вони успадковували --color-text або токен‑колір. Для растрових зображень вирішіть: залишаються вони такими ж, чи у вас є темні варіанти? Якщо це критичні зображення продукту (карти, діаграми), ймовірно, вам потрібні альтернативи.

Перемикач: машина станів, а не домисли

Ваш JavaScript має три завдання:

  1. Визначити збережену преференцію (system, light, dark).
  2. Встановити data-theme до першого рендеру, коли це можливо.
  3. Надати UI‑перемикач, що змінює збережену преференцію і оновлює DOM.

Мінімальна, надійна реалізація

Тримайте логіку маленькою. Зробіть її нецікавою. Винахідливість має бути у вашому продукті, а не в обвісці теми.

cr0x@server:~$ cat theme.js
(function () {
  const STORAGE_KEY = "theme-preference"; // "system" | "light" | "dark"
  const root = document.documentElement;

  function readPreference() {
    try {
      const v = localStorage.getItem(STORAGE_KEY);
      if (v === "light" || v === "dark" || v === "system") return v;
    } catch (e) {}
    return "system";
  }

  function writePreference(value) {
    try {
      localStorage.setItem(STORAGE_KEY, value);
    } catch (e) {}
  }

  function applyPreference(value) {
    root.setAttribute("data-theme", value);
  }

  function cyclePreference(current) {
    // Opinionated: cycle system -> light -> dark -> system
    if (current === "system") return "light";
    if (current === "light") return "dark";
    return "system";
  }

  // Early apply on load
  const initial = readPreference();
  applyPreference(initial);

  // Export small API for the button
  window.theme = {
    get: readPreference,
    set: (v) => { writePreference(v); applyPreference(v); },
    cycle: () => {
      const next = cyclePreference(readPreference());
      writePreference(next);
      applyPreference(next);
      return next;
    }
  };
})();

Це навмисне нецікаво. Це комплімент. Кнопка перемикання може викликати window.theme.cycle() і оновлювати свою підписку/лійбл.

Слухайте зміни системи (але лише в режимі system)

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

cr0x@server:~$ cat theme-system-listener.js
(function () {
  const media = window.matchMedia("(prefers-color-scheme: dark)");
  function onChange() {
    const pref = window.theme && window.theme.get ? window.theme.get() : "system";
    if (pref === "system") {
      document.documentElement.setAttribute("data-theme", "system");
    }
  }
  if (media.addEventListener) media.addEventListener("change", onChange);
  else if (media.addListener) media.addListener(onChange);
})();

Зверніть увагу, що він не робить: не перезаписує localStorage. Зміни системних налаштувань не повинні перезаписувати наміри користувача. Ваш застосунок лише повторно обчислює ефективні кольори через медіа‑запит у CSS.

Як позбутися мерехтіння (FOUC/FOWT) без хаків

Мерехтіння неправильної теми зазвичай самостійно нанесене: ви завантажуєте CSS, що залежить від атрибуту data-theme, але встановлюєте цей атрибут лише після рендеру. Користувач бачить тему за замовчуванням на частку секунди. На швидкому десктопі це миготіння. На середньому телефоні з холодним кешем — стробоскоп.

Краща практика: вбудуйте маленький «bootstrap» скрипт теми в

Так, вбудований. Так, перед вашим CSS, якщо можете. Це не «JS‑блоат», це коректність. Тримайте його маленьким і синхронним.

cr0x@server:~$ cat theme-bootstrap-inline.js
(function () {
  try {
    var v = localStorage.getItem("theme-preference");
    if (v !== "light" && v !== "dark" && v !== "system") v = "system";
    document.documentElement.setAttribute("data-theme", v);
  } catch (e) {
    document.documentElement.setAttribute("data-theme", "system");
  }
})();

Потім ваш CSS‑медіа‑запит для system бере на себе. Браузер може обчислити стилі до першого рендеру.

А що з CSP?

Якщо ваш CSP забороняє вбудовані скрипти, у вас є компроміс: погодитися на мерехтіння, дозволити невеликий вбудований скрипт через nonce/hash або виконувати серверний вибір теми через cookie. У корпоративному середовищі зазвичай перемагає CSP; плануйте це заздалегідь, а не дізнавайтеся після security‑ревю.

Ще одна річ: вкажіть color-scheme

Навіть якщо ви опрацьовуєте фон і текст, нативні контролери можуть відставати. Декларування color-scheme: light dark; у :root допомагає браузерам рендерити елементи форм у відповідному стилі. Це не досконале скрізь, але дешево й зазвичай правильно.

SSR, гідрація і чому важливий перший рендер

Клієнтські застосунки можуть дозволити собі трохи невизначеності. SSR‑застосунки — ні. З SSR користувачі бачать HTML і CSS до того, як завантажиться ваш бандл. Якщо сервер віддає світлу тему, а клієнт після гідрації вирішує темну, ви отримаєте видимий фліп і іноді зміни макета (шрифти, рамки, зображення). Схоже на перезавантаження сторінки.

Опції серверного рішення

  • Оверрайд через cookie: якщо користувач встановив theme=dark, сервер може віддати темну тему відразу.
  • Системна преференція: сервер не може надійно знати prefers-color-scheme з HTTP‑запиту. Деякі нові client hints існують, але сприймайте їх як опціональні.
  • Гібрид: сервер рендерить за замовчуванням з data-theme="system". Якщо cookie вказує оверрайд, встановіть data-theme відповідно.

Гібрид працює добре: нехай CSS і медіа‑запити керують системною преференцією; cookie — явними оверрайдами. Якщо ви робите так, тримайте область cookie послідовною між субдоменами, що ділять той самий UI; нічого не каже «послідовний набір продуктів», як коли кожен субдомен по‑різному вирішує, якої теми ви хочете.

Гідраційні невідповідності: класична підстава для багів

Якщо ваш фреймворк клієнтської частини рендерить тематично‑специфічну розмітку (наприклад, різні SVG або різні дерева компонентів), а сервер віддав іншу тему, ви можете отримати попередження гідрації або повний повторний рендер. Виправлення: зберігайте розмітку однаковою між темами і змінюйте лише презентацію через CSS‑змінні.

Доступність: контраст, фокус і зменшена анімація

Темний режим може бути комфортнішим для одних очей і гіршим для інших. Універсального «комфорту» не існує. Ваше завдання — не робити інтерфейс нечитаємим або фізично неприємним.

Контраст не опціональний

У темних темах дизайнери часто обирають приглушений сірий текст на майже‑чорному фоні. Це виглядає «преміально». Воно також не проходить по контрасту і робить читання важким. Якщо вам потрібен приглушений тон, використовуйте його економно (для вторинних міток), а не для основного контенту.

Фокус‑кільця мають виживати в темі

Багато команд видаляють outline з естетичних міркувань, а потім забувають повернути його. Темна тема з невидимим фокусом функціонально зламана для користувачів клавіатури. Використовуйте токен типу --color-focus і тримайте його достатньо яскравим для обох тем.

Дотримуйтесь reduced motion

Перехід між темами (зміна кольорів з анімацією) може виглядати красиво, але викликати чутливість до руху, якщо перебільшити. Повага до prefers-reduced-motion — обов’язкова; відключайте або скорочуйте переходи.

cr0x@server:~$ cat motion.css
@media (prefers-reduced-motion: reduce) {
  * {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}

Короткий жарт №2: Якщо ви анімуєте перемикання теми 600 мс, у ваших користувачів буде час заварити чай і передумати щодо вашого продукту.

Спостережуваність: вимірюйте використання й збої

Темний режим — це фіча продукту. Тому її варто вимірювати. Не обсесивно. Достатньо, щоб ловити регресії і розуміти, чи працює перемикач так, як ви думаєте.

Що логувати (а що ні)

  • Логувати: переходи станів преференції теми (system → dark, dark → system).
  • Логувати: ефективна тема на першому рендері (якщо можете інструментувати), щоб виявляти випадки мерехтіння.
  • Не логувати: «у користувача темні системні налаштування» як атрибут користувача, прив’язаний до ідентичності, без урахування конфіденційності. Це виявляється досить фингерпринтним у поєднанні з іншими сигналами.

Практичний підхід: відправляйте аналітику при взаємодії користувача з перемикачем. Окремо збирайте клієнтські метрики для «перший рендер — матч» з низьким відбором. Якщо після релізу раптово зростає частка невідповідностей, ви точно знаєте, куди дивитись: bootstrap‑скрипт, SSR‑шаблон або порядок CSS.

Три корпоративні історії з поля бою тем

1) Інцидент через хибне припущення

Компанія випустила новий хедер як частину оновлення дизайн‑системи. Він чудово виглядав у світлій темі. У темній теж ніби нормально — поки не відкриєш випадаюче меню. Фон меню був темний, але текст у меню залишився темно‑сірим. Це було не просто низьким контрастом; текст став невидимим.

Хибне припущення було тонким: команда вірила, що «темна тема» — це просто заміна фонового й текстового кольору на рівні сторінки. Компонент випадаючого меню, однак, мав закодовані кольори у вкладеному стилі, який ніколи не використовував токенів. У README компонента писали «підтримує теми», але лише на рівні оболонки.

Спочатку з’явилися звернення у сапорт. Потім внутрішні користувачі почали робити скриншоти й кидати їх у чат з підписами «це прапор для сліпоти?» Інцидент оголосили, бо випадачка контролювала налаштування безпеки акаунта. Люди не могли вийти або керувати сесіями в темній темі, а застосунок за замовчуванням вмикав темну для частини користувачів.

Виправлення не було героїчним. Вони ввели семантичні токени і вимагали, щоб кожен компонент використовував їх, з lint‑правилами, що відкидають сирі кольори, окрім файлів токенів. Потім додали візуальні регресійні тести для ключових флоу у двох темах. Урок: припущення про «глобальне темування» валиться у момент, коли компонент відправляється з жорстко‑закодованими кольорами.

2) Оптимізація, що відбулася боком

Інша команда вирішила позбутися маленького inline‑bootstrap скрипта тем через обмеження бюджету і через те, що security не любив inline JS. Вони перемістили ініціалізацію теми у головний бандл і використали defer. Також додали плавний fade при перемиканні, щоб виглядало «преміально».

У лабораторії все було ок. На реальних пристроях — хаос. Користувачі на повільних мережах бачили біле мерехтіння, потім перехід у темну, потім ще одне мерехтіння, коли гідрація замінювала серверний HTML. Переходи посилили проблему: замість короткого бліпу це стало помітною анімацією, що привертала до себе увагу.

У webview у нативних застосунках стало ще гірше. Webview іноді затримував JS‑бандл більше, ніж очікувалось, тож початкова тема трималась секунди. Люди думали, що перемикач «не працює», бо він працював — просто не в розумні для людини терміни.

Вони відкотили перехід, повернули CSP‑хешований inline‑скрипт і запровадили cookie‑оверайд для SSR. Практична продуктивність покращилась, бо користувачі припинили тригерити перерендери й зсуви макета через фліпи теми. Урок: оптимізація під неправильну метрику (чистота бандлу) може погіршити видиму продуктивність.

3) Нудна, але правильна практика, що врятувала ситуацію

Третя організація зробила те, що здавалося болісно непримітним: створили документ‑контракт для теми і невеликий набір тестів конформності для компонентів. Кожен компонент мав коректно рендеритись з data-theme="light", "dark" і "system" при обох налаштуваннях prefers-color-scheme у тестовому раннері.

Також стандартизували іменування токенів і заборонили ad‑hoc змінні в CSS компонентів. Потрібен новий відтінок? Додай токен, обґрунтуй і проклади його через обидві теми. Перші PR сповільнились. Люди скаржились. Потім скарги припинились, бо правила стали передбачуваними.

Через квартал відбулась велика ребрендингова хвиля: новий акцент, новий відтінок фону, оновлені тіні. Команди очікували колапсу. Натомість вони змінили значення токенів, прогнали тести і задеплоїли. «Нудна» дисципліна означала, що не довелось шукати сотні CSS‑файлів із hex‑значеннями як археологам, що відкопують погане рішення.

Урок: найкраща система тем — це здебільшого процес. Код — легка частина.

Практичні завдання (з командами) для відлагодження і прийняття рішень

Це завдання, які ви можете виконати зараз на dev‑машині або у CI. Кожне містить: команду, що означає вивід, і яке рішення з цього випливає. Мета — операційна: зменшити містичність, зменшити вгадування.

Завдання 1: Перевірте, чи зібраний CSS реально містить селектори тем

cr0x@server:~$ rg -n 'data-theme="dark"|prefers-color-scheme' dist/assets/*.css
dist/assets/app.9c31.css:12::root[data-theme="dark"]{--color-bg:#0b1220;...}
dist/assets/app.9c31.css:38:@media (prefers-color-scheme: dark){:root[data-theme="system"]{...}}

Значення: Ви бачите і явний оверрайд, і медіа‑запит для режиму system у зібраному артефакті.

Рішення: Якщо цих рядків немає, ваш збірник або видалив, або ніколи не включив CSS теми. Виправте порядок імпортів або конфіг збірки перед відлагодженням UI.

Завдання 2: Виявити жорстко закодовані hex‑кольори у CSS компонентів (обхід токенів)

cr0x@server:~$ rg -n --glob='**/*.css' '#[0-9a-fA-F]{3,8}\b' src/
src/components/dropdown.css:44:color: #111827;
src/components/dropdown.css:51:background: #ffffff;

Значення: Компоненти оминають токени; вони, ймовірно, зламаються в одній з тем.

Рішення: Замініть на семантичні змінні (наприклад, var(--color-text), var(--color-surface)) і дозволяйте сирі hex тільки у файлах токенів.

Завдання 3: Підтвердити, що корінь HTML має очікуваний атрибут у спокої

cr0x@server:~$ node -e "const {JSDOM}=require('jsdom'); const html='<!doctype html><html data-theme=\"dark\"></html>'; const dom=new JSDOM(html); console.log(dom.window.document.documentElement.getAttribute('data-theme'));"
dark

Значення: Ваші шаблони/SSR можуть встановлювати атрибут.

Рішення: Якщо SSR ніколи не встановлює data-theme, ви маєте покладатися на клієнтський bootstrap і погодитись на можливе мерехтіння — або реалізувати вибір теми через cookie на сервері.

Завдання 4: Перевірка поведінки збереження в localStorage у безголовому браузері

cr0x@server:~$ node -e "console.log('Simulate: read=system when empty, store=dark');"
Simulate: read=system when empty, store=dark

Значення: Це заповнювач‑перевірка для CI: упевніться, що ваші шляхи коду обробляють відсутні/некоректні значення і не падають.

Рішення: Якщо у вас викидаються виключення при доступі до сховища у приватному режимі або в жорстких середовищах, додайте try/catch і дефолт до system.

Завдання 5: Перевірити заголовки CSP на придатність inline‑скриптів

cr0x@server:~$ curl -sI http://localhost:3000 | rg -i 'content-security-policy'
content-security-policy: default-src 'self'; script-src 'self'

Значення: Inline‑скрипти блокуються (немає 'unsafe-inline', немає nonce, немає hash).

Рішення: Або додайте nonce/hash для крихітного bootstrap‑скрипта, або використайте SSR‑вибір теми через cookie, щоб уникнути мерехтіння.

Завдання 6: Переконатися, що сервер встановлює cookie теми при оверрайді користувача

cr0x@server:~$ curl -sI http://localhost:3000/set-theme?value=dark | rg -i 'set-cookie'
set-cookie: theme=dark; Path=/; SameSite=Lax

Значення: Сервер може зберегти оверрайд так, щоб SSR його читав.

Рішення: Якщо ви не бачите Set-Cookie, SSR не може знати про оверрайди; доведеться йти шляхом inline‑bootstrap.

Завдання 7: Перевірити, що cookie надсилається назад у наступному запиті

cr0x@server:~$ curl -sI --cookie "theme=dark" http://localhost:3000 | rg -i 'data-theme|set-cookie'
set-cookie: session=...; Path=/; HttpOnly; SameSite=Lax

Значення: Cookie присутній і запит пройшов. (У заголовках ви не побачите data-theme; HTML перевіряйте далі.)

Рішення: Перейдіть до отримання HTML і перевірте, чи SSR використав cookie.

Завдання 8: Інспектувати SSR HTML на коректність атрибуту теми

cr0x@server:~$ curl -s --cookie "theme=dark" http://localhost:3000 | head -n 5





Значення: SSR рендерить правильну тему відразу.

Рішення: Якщо SSR все ще видає system, ваш сервер не читає cookie (або порядок middleware некоректний).

Завдання 9: Переконатися, що ваш CSS декларує color-scheme для вирівнювання нативного UI

cr0x@server:~$ rg -n 'color-scheme:\s*light\s+dark' src/**/*.css
src/styles/theme.css:2:  color-scheme: light dark;

Значення: Браузерам є підказка, як темізувати нативні віджети.

Рішення: Якщо відсутній — додайте; потім перетестуйте контролери форм і смуги прокрутки на різних платформах.

Завдання 10: Ловити випадкові перевизначення теми від стороннього CSS

cr0x@server:~$ rg -n 'background:\s*#fff|color:\s*#000' node_modules/some-widget/dist/widget.css
node_modules/some-widget/dist/widget.css:88:background: #fff;
node_modules/some-widget/dist/widget.css:89:color: #000;

Значення: Сторонній stylesheet жорстко закодував кольори і виглядатиме неправильно в темній темі.

Рішення: Обгорніть його (shadow DOM або контейнер з перевизначеннями), запатчіть через CSS‑змінні, якщо підтримується, або замініть віджет. Не сподівайтесь, що він «сам вирішиться».

Завдання 11: Перевірте, чи стилі друку не видадуть чорну сторінку на папері

cr0x@server:~$ rg -n '@media\s+print' src/styles/*.css
src/styles/print.css:1:@media print {

Значення: У вас є явна обробка для друку.

Рішення: Якщо відсутня — додайте print‑stylesheet, що примусово встановлює світлий фон і темний текст для друку, якщо тільки ваш продукт свідомо не потребує темного друку.

Завдання 12: Перевірте, що reduced motion враховано для переходів теми

cr0x@server:~$ rg -n 'prefers-reduced-motion' src/styles/**/*.css
src/styles/motion.css:1:@media (prefers-reduced-motion: reduce) {

Значення: Ви щонайменше подумали про чутливість до руху.

Рішення: Якщо відсутнє і у вас є переходи кольорів/фонів, додайте guard для reduced‑motion.

Завдання 13: Перевірка, що ваш перемикач доступний і підписаний (статична перевірка)

cr0x@server:~$ rg -n 'aria-label="Theme"|aria-pressed|role="switch"' src/
src/components/ThemeToggle.tsx:18:<button aria-label="Theme" aria-pressed={...}>

Значення: Ваш перемикач, ймовірно, експонує стан для допоміжних технологій.

Рішення: Якщо немає підпису або атрибуту стану — виправте перед релізом; регресії доступності — це не «прикраса».

Завдання 14: Переконайтеся, що збірка не перемішала порядок CSS так, що ламається пріоритет

cr0x@server:~$ ls -lh dist/assets/*.css
-rw-r--r-- 1 cr0x cr0x 214K Dec 29 10:22 dist/assets/app.9c31.css
-rw-r--r-- 1 cr0x cr0x  48K Dec 29 10:22 dist/assets/vendor.1a02.css

Значення: У вас кілька CSS‑файлів; порядок має значення.

Рішення: Забезпечте, щоб визначення токенів завантажувались перед CSS компонентів, а перевизначення тем — після дефолтових стилів. Якщо vendor CSS підвантажується останнім, він може перезаписати ваші кольори.

Швидкий план діагностики

Коли темний режим «зламаний», не починайте з переписування CSS. Почніть з пошуку, де саме істина розходиться: збережена преференція, атрибут у DOM, обчислені токени чи стилі компонентів.

По‑перше: визначте обрану преференцію і ефективну тему

  • Перевірте document.documentElement.dataset.theme у консолі DevTools.
  • Перевірте значення у сховищі/cookie для ключа theme-preference (або вашого ключа).
  • Перевірте налаштування ОС/браузера: чи співпадає prefers-color-scheme: dark з тим, що ви очікуєте?

Якщо преференція неправильна: логіка перемикача/збереження зламана. Спочатку виправте JS і збереження.

По‑друге: перевірте поведінку першого рендеру (полювання на мерехтіння)

  • Жорстке оновлення з вимкненим кешом. Спостерігайте за мерехтінням.
  • Перевірте, чи присутній data-theme в SSR HTML або встановлений рано вбудованим скриптом.
  • Перевірте CSP: якщо inline‑скрипти блоковані і SSR не встановив тему, мерехтіння неминуче.

Якщо перший рендер неправильний: перемістіть вибір теми раніше (SSR cookie або inline bootstrap).

По‑третє: ізолюйте помилки токенів CSS від жорстких кольорів компонентів

  • Інспектуйте непрочитуваний компонент і подивіться обчислені стилі: чи беруться значення з var(--...), чи литі кольори?
  • Якщо літні кольори: компонент оминув токени або сторонній CSS перезаписав їх.
  • Якщо токени є, але неправильні: значення токенів не перевизначаються для активної теми.

Якщо токени не перевизначаються: виправте специфічність/порядок селекторів (:root[data-theme="dark"] не застосовується) і перевірте порядок завантаження CSS.

Типові помилки: симптом → причина → виправлення

1) Симптом: темна тема працює після перемикання, але скидається після оновлення сторінки

Причина: стан зберігається в пам’яті (стан фреймворку), а не персиститься; або запис у сховище падає (приватний режим, заблоковане сховище, виключення).

Виправлення: зберігайте theme-preference у localStorage з try/catch; дефолт до system, коли сховище недоступне.

2) Симптом: сторінка блимає світлою, а потім перемикається в темну

Причина: data-theme застосовується після першого рендеру (бандл завантажився пізно), або SSR віддав іншу тему, ніж клієнт обчислює.

Виправлення: вбудуйте мінімальний bootstrap‑скрипт (nonce/hash якщо треба) або рендеріть оверрайд через cookie у SSR. Тримайте розмітку нейтральною; змінюйте вигляд через токени.

3) Симптом: лише деякі компоненти перемикаються

Причина: компоненти жорстко кодують кольори або використовують власний механізм тем; існує кілька коренів теми.

Виправлення: примусово використовуйте токени; зробіть <html data-theme> єдиним джерелом істини; видаліть конкурентні класи.

4) Симптом: форми виглядають неправильно (білі input на темному фоні)

Причина: відсутній color-scheme; нативні контролі не поінформовані; частково перевизначені стилі компонентів.

Виправлення: встановіть color-scheme: light dark; у :root; явно стилізуйте контролі форм через токени там, де потрібно.

5) Симптом: графіки нечитаємi в темній темі

Причина: палітра візуалізації налаштована для світлого фону; лінії сітки/осей мають низький контраст; текст canvas/SVG не темізований.

Виправлення: визначіть токени для графіків (вісь, сітка, палітра серій) і перемикайте їх для кожної теми; тестуйте з реальними даними, а не демо.

6) Симптом: системні налаштування не застосовуються коли в «system»

Причина: відсутній медіа‑запит у CSS або він перекритий; застосунок зберіг двійкове dark/light і ніколи не перевіряє заново.

Виправлення: зберігайте преференцію у трьох станах; використовуйте @media (prefers-color-scheme: dark) для data-theme="system" перевизначень.

7) Симптом: перемикач недоступний або незрозумілий

Причина: контроль лише з іконкою без підпису; стан не відображається (aria-pressed відсутній); стан «system» не представлений.

Виправлення: використовуйте підписану кнопку або switch; додайте «System / Light / Dark» варіанти або циклічний перемикач з чітким тултіпом і доступною назвою.

8) Симптом: друк/PDF виходить чорними сторінками або марнотратним інксом

Причина: стилі теми застосовуються для друку; немає override для друку.

Виправлення: додайте CSS для друку, що примусово встановлює світлу палітру і вимикає декоративні фони.

Чеклисти / покроковий план

Покроковий план впровадження (патерн, що тримається)

  1. Визначте модель преференції: system|light|dark. Запишіть це; зробіть частиною контракту продукту.
  2. Виберіть один шлях персистенції: localStorage для клієнтських застосунків; cookie якщо SSR потребує першого рендеру для оверрайдів.
  3. Реалізуйте один корінь теми: <html data-theme="system">. Все інше посилається на нього.
  4. Створіть семантичні токени: background, surface, text, muted, border, focus, link, shadows.
  5. Реалізуйте CSS‑перевизначення: :root[data-theme="dark"] і @media (prefers-color-scheme: dark) :root[data-theme="system"].
  6. Встановіть color-scheme: color-scheme: light dark; у :root.
  7. Inline bootstrap (або SSR cookie): забезпечте наявність data-theme до першого рендеру.
  8. Побудуйте UI‑перемикач: доступна підпис, експозиція стану (aria-pressed або role="switch"), явна обробка «system».
  9. Аудит компонентів на сирі кольори: видаліть або загорніть їх за токенами.
  10. Тестуйте критичні флоу в обох темах: автентифікація, налаштування, таблиці, графіки, модальні вікна, тости, сторінки помилок.
  11. Опрацюйте друк: забезпечте друкарсько‑дружню палітру.
  12. Додайте спостережуваність: відстежуйте використання перемикача і частку невідповідностей першого рендеру (семпл).

Чеклист перед релізом (що перевірити)

  • Жорстке оновлення на повільній мережі: немає мерехтіння неправильної теми.
  • Тема зберігається після оновлення і в новій вкладці.
  • Режим system слідує ОС без перезапису збереженої преференції.
  • Форми, модали, меню читабельні в обох темах.
  • Фокус‑кільця видимі і послідовні.
  • Графіки та таблиці проходять «squint‑тест» у темній темі.
  • Вивід для друку адекватний.
  • Сторонні віджети не ламають тему.

Поширені питання

Чи має перемикач бути двостановим чи трьохстановим (system/light/dark)?

У реальному світі трьохстановий варіант перемагає. Двоєстороний змушує вгадувати, що означає «вимкнено», і робить користувачів прив’язаними до старого вибору, коли налаштування ОС змінюється.

Чи достатньо prefers-color-scheme без перемикача?

Ні. Деякі користувачі хочуть протилежного системного налаштування для конкретного сайту (робота vs особисті пристрої, яскраві офіси, відблиски). Дайте їм оверрайд.

Де має знаходитись атрибут теми: <html> чи <body>?

<html> — найчистіший корінь і добре працює з оголошеннями токенів у :root. Виберіть одне і будьте послідовні.

Чому не просто переключати клас .dark?

Можете, але селектори атрибутів легше розширювати (system/light/dark) і зменшують колізії з іншим CSS. Великий виграш — у послідовності, а не в синтаксисі.

Чи потрібно слухати зміни системної теми в JavaScript?

Не обов’язково, якщо ваш CSS використовує @media (prefers-color-scheme) для data-theme="system". Слухач може допомогти оновлювати мітки/іконки UI, але не переписуйте збережену преференцію.

Як уникнути невідповідностей гідрації в SSR‑фреймворках?

Тримайте структуру DOM однаковою між темами. Використовуйте CSS‑змінні для відмінностей у поданні. Якщо потрібно міняти розмітку, вирішіть тему на сервері через cookie для оверрайдів.

Як називати токени?

Називайте за наміром: --color-surface, а не --gray-950. Майбутнє ви‑подякуєте собі, коли брейндинг зміниться. Сьогоднішнє «я» теж потайки подякує.

Чи завжди темна тема економить батарею?

Не завжди. На OLED часто так. На LCD підсвітка лишається увімкненою, тож економія менша. Запускайте темний режим заради зручності та уподобань; економія енергії — приємний побічний ефект.

Що з високим контрастом / примусовими кольорами?

Не боріться з ними. Уникайте жорстко закодованих фонів під текстом, тримайте стилі фокуса видимими і тестуйте поведінку forced‑colors. Якщо дизайн там ламається — це не «крайній випадок», це борг доступності.

Чи варто анімувати переходи тем?

Зазвичай: ні, або робіть дуже тонко і відключайте під prefers-reduced-motion. Тема — це стан, не нічний клуб.

Висновок: наступні кроки, які можна відправити в продакшн

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

Практичні наступні кроки

  1. Реалізуйте трьохстанову преференцію і встановіть data-theme на <html>.
  2. Перенесіть усі значення теми у семантичні CSS‑змінні і видаліть жорстко закодовані кольори в компонентах.
  3. Додайте або inline‑bootstrap скрипт (CSP‑безпечний через nonce/hash), або SSR cookie‑селектор для оверрайдів, щоб позбутися мерехтіння.
  4. Проганяйте grep‑аудити (сирі hex, відсутній color-scheme, vendor CSS, що перезаписує) і виправляйте найгірші винятки першими.
  5. Тестуйте критичні флоу в обох темах і для друку, потім зафіксуйте це автоматичними перевірками.
← Попередня
Рідинне охолодження GPU: розумне рішення чи дороге косплей?
Наступна →
ATI проти NVIDIA: чому це суперництво не закінчується

Залишити коментар