Главная / Статьи

Сравнение девяти техник режима темной темы: от хаков с фильтрами до серверных куки

Сравните девять способов добавления режима темной темы в веб-приложение — от инверсии фильтров до токенов, функции light-dark() и серверных куки — и узнайте, какие ошибки тихо возникают при использовании каждого из них.

Издатель: REACTAPP.TOP24 мин чтения5596 слов

Источники: Dark Mode on the Web, Ranked: 9 Ways to Theme a Page and What Each One Actually Breaks

Режим темноты обычно рассматривается как выбор между временным решением и подлинным способом устранения проблемы, однако на производственных сайтах используется как минимум девять различных техник, причем каждая из них решает лишь часть проблемы. Те аспекты, которые игнорируются той или иной техникой, позже проявляются в виде мерцающих страниц, сломанных фиксированных заголовков или цветов, которые неподвижно остаются прежними. В этом руководстве приводится классификация этих девяти подходов, объясняются их сильные и слабые стороны, а также рассматриваются детали, которые могут сорвать даже тщательную реализацию: появление неправильной темы, свойство color-scheme, настройки трех состояний, анимации переходов, встроенный контент и дизайн палитры цветов.

Утверждения о поведении, приведенные ниже, основаны на первоисточниках: черновиках рабочей группы CSS, стандарте HTML WHATWG, машинно-читаемых данных browser-compat-data, лежащих в основе MDN, данных о статусе базовой версии и исходном коде библиотеки next-themes; все это было сопоставлено с результатами тестирования в безголовом Chromium. Два распространенных мнения не выдерживают такой проверки, а одно из проявлений поведения — фильтрация потомков с свойством position: fixed — оказывается гораздо более веской причиной для избегания приема инверсии, чем обычно приводимый расплывчатый аргумент о производительности.

Темный режим — это три отдельных проблемы

«Добавить темный режим» звучит как одна задача. На практике это включает три независимых друг от друга вопроса, из-за которых даже при идеальном решении одного из них можно выпустить несовершенный продукт:

  1. Какую тему следует отображать? У операционной системы есть предпочтение, пользователь может захотеть его изменить, а ему также необходим способ вернуться к настройкам системы. Простой переключатель включения/выключения полностью устраняет эту третью опцию.
  2. Как меняются цвета? Один переключатель должен обновлять все поверхности, границы, иконки и тени, причем его расположение фактически определяет архитектуру CSS.
  3. Когда применяется тема? Если решение принимается после первой отрисовки, пользователи видят, как цвета страницы меняются прямо у них на глазах.

Каждая из описанных ниже техник отвечает на часть этих вопросов. Образец ясен: так называемые «ленивые» подходы обычно решают второй вопрос изолированно, полностью игнорируя первый и третий.

Как читать рейтинг

Три лучших варианта не являются конкурентами. Они объединяются в одну схему: семантические токены служат основой, 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 градусов не является точной обратной инверсией в любом цветовом пространстве.
  • Цвета бренда превращаются в их математические противоположности вместо заданной темной палитры.
  • Никакие авторитетные рекомендации этого не предлагают. В руководстве web.dev по темному режиму нигде не говорится о необходимости инвертирования всей страницы; для медиаконтента предлагается использовать filter: grayscale(50%) для фотографий, а invert(100%) — только для однотонных иконок.
  • Собственные рекомендации Chrome относительно автоматической инверсии заключаются в создании специальной темы для темного режима вместо отключения этой функции.
  • Места 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 ролей.

    Два отдельных таблицы стилей

    Загрузка таблицы стилей для светлой и тёмной тем через атрибуты медиа кажется эффективной:

    <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 считывает только настройки операционной системы, поэтому никакое ручное изменение не может на него повлиять; со временем эти два файла могут отклоняться друг от друга; к тому же инструменты сборки кода могут неправильно обрабатывать эту пару, как описано в задаче 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 предупреждает, что в будущем у этой функции может появиться больше значений, примером служит sepia, и рекомендует тестировать с использованием отрицания: (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 с принудительным использованием разных схем цветов показала три важных вывода:

    1. Без параметра color-scheme функция light-dark() не выполняет никаких действий. В режиме темной темы она без проблем возвращает светлые цвета, и в консоли не появляется никаких сообщений об ошибке. Это самая распространенная ошибка функции light-dark(), которая остается незамеченной до тех пор, пока кто-то из пользователей в режиме темной темы не сообщит об этом.
  • Установка свойства color-scheme: dark на элемент заставляет его использовать второй аргумент, независимо от настроек операционной системы. Именно поэтому ручная смена режима осуществляется путем изменения одного свойства, а не замены класса вместе с добавлением параллельного набора правил.
  • Установка color-scheme: dark на элемент, отличный от корневого, не приводит к темному фону для этого элемента. Она изменяет цвета системы и встроенные контроллы, но фон канваса следует настройкам только корневого элемента. Это самое распространенное недопонимание этого свойства.
  • В качестве проверки для вашей собственной палитры в темном режиме 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 и базовые 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, при этом используется тема light в качестве альтернативы:

    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 в функции сервера или обработчике маршрута, но никогда во время отрисовки.
  • Каждый запрос содержит куки, что добавляет примерно 15 байт.
  • Настройки операционной системы невидимы для сервера, поэтому выбор 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 выбрасывает исключения в режиме приватного просмотра и в iframe с изолированным окружением.

    Настоящая проблема заключается в политике безопасности: для встроенного скрипта требуется параметр '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()

    В корневом элементе схема также управляет цветом поверхности холста и ползунками прокрутки области отображения. Ее необходимо указать в трех местах. Во-первых, в теге 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.10.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)" следует настройкам операционной системы. Он не может учитывать ваш атрибут theme или параметр 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, но начиная с версии 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 с 09.11.2025 года. Следует иметь в виду, что система elevation-overlay из Material Design 2 устарела: в документации Google говорится, что эти оверлеи были заменены на систему цветов поверхности, и к ним больше не предоставляется поддержка. В Material 3 используются роли от surfaceContainerLowest до surfaceContainerHighest, а также surfaceDim и surfaceBright. Ссылки, приводящие старую таблицу с значениями от 5 процентов при размере 1dp до 16 процентов при размере 24dp, относятся к устаревшей системе.

    Снижение насыщенности акцентных цветов

    Насыщенные акценты средних оттенков вибрируют на темных поверхностях. OKLCH делает эти корректировки систематическими: его канал светлости имеет воспринимаемую однородность, поэтому шаги с четными числовыми значениями выглядят такими же однородными, в отличие от 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, однако он не входит в состав WCAG 2.2 и пока не является обязательным нигде.

    Что говорят исследования о темном режиме и читаемости

    Этот момент часто излагается неправильно. В кратком обзоре исследований от Nielsen Norman Group говорится:

    • У людей с нормальным зрением в исследованиях Piepenbrock et al. (2013, Ergonomics) и Dobres et al. (2017, Applied Ergonomics) было установлено, что режим светлого фона показывал лучшие результаты по всем критериям. Преимущество увеличивается по мере уменьшения размера шрифта. Причина кроется в оптике: темный текст на светлом фоне отражает больше света, зрачок сужается, а меньший зрачок приводит к уменьшению сферических аберраций и увеличению глубины резкости.
    • У людей с ослабленным зрением Legge et al. (1985, Vision Research) обнаружили, что все семь участников с нарушениями структуры глазного яда, в частности катарактой, читали быстрее в режиме темного фона.
    • В долгосрочной перспективе Aleman et al. (2018, Scientific Reports) считают, что постоянное использование режима светлого фона может способствовать развитию близорукости из-за утончения сосудистой оболочки глаза.
    • Практическая рекомендация заключается в том, чтобы позволять пользователям переключаться на режим темного фона, если они этого хотят.

    Честный вывод заключается в том, что режим темного цвета служит личным предпочтениям и определенным потребностям в доступности; он не способствует улучшению читаемости для широкой аудитории. Именно это является самым убедительным аргументом в пользу использования трех состояний управления вместо того, чтобы сразу ставить режим темного цвета в качестве стандартного.

    Основные выводы

    • Рассматривайте режим темного цвета как трехэтапный процесс: выбор темы, смена цветов и применение выбранного варианта до первой отрисовки.
    • Называйте элементы в соответствии с их функциями, меняйте их значение в одном блоке data-theme, а также синхронизируйте значение color-scheme на корневом уровне с ним с помощью мета-тега.
    • Определяйте тему до первой отрисовки — либо с помощью куки, доступной сервером, либо с использованием встраиваемого скрипта, и принимайте связанные с этим ограничения CSP.
    • Предоставляйте три варианта: светлый, темный и системный, и обновляйте их только при выборе режима системы.
  • Если никому не нужен переключатель, использование prefers-color-scheme вместо слоя токенов — это самое быстрое и простое решение.
  • Никогда не используйте filter: invert() на странице, которой вы управляете: это превращает корневый элемент в контейнер для всех фиксированных элементов.
  • Проверяйте отдельно контрастность, изображения, iframe и поведение принудительных цветов для темной темы; изменение токенов не исправляет их автоматически.