Logo

Реализация переключения цветовой схемы на сайте

Создание переключателя ночной и дневной темы, как для стилей, так и изображений.

Современные операционные системы поддерживают светлую (дневную) и тёмную (ночную) темы оформления интерфейса уже несколько лет, поддержка тем появилась и во всех основных браузерах, поэтому иметь современный сайт без поддержки различных тем просто неприлично и является неуважением к пользователю.

Соответствие настройкам текущей темы операционной системы может быть реализовано на уровне CSS автоматически, но в данной заметке будет разобран подход, который даёт возможность пользователю самостоятельно выбрать тему в любой момент.

Базовая матчасть

Медиа-запросы

Язык CSS поддерживает медиа-запросы (media queries) – это правила, которые позволяют реализовывать адаптивную вёрстку на основе тех или иных характеристик устройства пользователя. К примеру, если устройство пользователя это монитор, то можно применить одни стили, если это телевизор, то другие, а если принтер, то третьи. Можно контролировать размеры устройства, ориентацию, ну и конечно же особенности среды, в том числе и выбранную в системе (либо браузере) тему.

Ключевое слово для медиа-запроса это @media. Фактически это обёртка для стиля: всё, что находится внутри, применяется или не применяется в зависимости от того, выполняется ли условие:

@media (/* условие */) {
    /* стиль будет применён только если условие истинно */
}

Чтобы узнать, какая тема используется в системе, необходимо использовать медиа-свойство prefers-color-scheme, оно может иметь два значения:

  • light – для светлого (дневного) режима. Так же данное значение используется по умолчанию.

  • dark – для тёмного (ночного) режима.

В качестве примера рассмотрим вот такой блок:

<div>
    <span class="theme light">Светлая тема</span>
    <span class="theme dark">Тёмная тема</span>
</div>

В зависимости от темы будет отображаться только одна надпись, причем у светлой темы линия подчеркивания будет розовая, а у тёмной оранжевая:

.theme {
    text-decoration-line: underline;
    text-decoration-thickness: 3px;
    text-decoration-color: #f76b8a; /* розовый по умолчанию */
}

.light {
    display: block;
}

.dark {
    display: none; /* при светлой теме текст не отображется */
    text-decoration-color: #f96d00; /* оранжевый для тёмной */
}

@media (prefers-color-scheme: dark) {
         /* Данная группа стилей будет использована
               только для тёмной темы */
    .light {
        display: none;
    }
    
    .dark {
        display: block;
    }
}
Светлая темаТёмная тема


Данный подход можно использовать только для автоматического соответствия теме заданной в системе.


Медиа-запросы в JavaScript

Чтобы в JS определить, соответствует ли документ некой строке медиа-запроса, следует использовать метод matchMedia() интерфейса window, где в качестве единственного аргумента передается запрос. Как и с кодом CSS, это будет строка (prefers-color-scheme: <light|dark>). Метод возвращает объект MediaQueryList у которого есть булево свойство matches, оно будет равно true если документ соответствует запросу и false в обратном случае. Так же имеются два метода addEventListener() и removeEventListener() позволяющие подписаться (либо отписаться) на событие change оповещающее об изменении состояния документа. Таким образом можно отслеживать изменение темы в системе в режиме реального времени, либо когда пользователь её меняет самостоятельно, либо когда это происходит автоматически по расписанию.

Пример:

const getCurrentTheme = () => {
    let e = window.matchMedia('(prefers-color-scheme: dark)');
    return ['Темная', 'Светлая'][+e.matches];
};
<button onClick="alert(getCurrentTheme())">Узнать тему...</button>
* значение текущей темы в системе



Медиа-запросы для изображений

Тег <picture> позволяет определить несколько источников (<source>) для <img> и с помощью медиа-запросов выбрать какой именно источник будет использоваться в конкретной ситуации. Если ни один источник не подходит, то будет использован файл по умолчанию указанный в теге <img>.

В примере ниже используется два изображения. Первое – это дополнительный источник который будет отображаться когда медиа-запрос будет верен, в данном случае это фильтр для тёмной (dark) темы в системе. Второе изображение – это изображение указанное в теге <img>, оно будет использоваться по умолчанию, соответственно когда у пользователя включена светлая тема.

Данный пример работает только при переключении темы в системе (браузере), кнопка “Переключить тему” здесь лишь эмулирует подобное поведение.

<picture>
    <source media="(prefers-color-scheme: dark)" srcset="at-night.jpg">
    <img src="daring-the-day.jpg">
</picture>
Девушка с Земли, автор оригинального рисунка Руслан Смак



Селекторы по атрибуту

HTML позволяет добавлять какие угодно пользовательские атрибуты, а CSS позволяет выборочно стилизовать такие элементы. Синтаксис в CSS довольно прост: атрибут записывается в квадратных скобках, можно указать и необходимое значение атрибута через знак равенства.

Рассмотрим пример, где двум элементам <span> добавляется атрибут theme, причем с различными значениями (orange и pink), благодаря чему к данным элементам будет применено своё уникальное правило:

.circle {
    background-color: #66bfbf;

    height: 40px;
    width: 40px;
    margin: 3px;
    border-radius: 50%;
}

.circle[theme=orange] {
    background-color: #f96d00; /* оранжевый */
}

.circle[theme=pink] {
    background-color: #f76b8a; /* розовый */
}
<div style="display: flex; justify-content: center;">
    <span class="circle" theme=orange></span>
    <span class="circle" theme=pink></span>
    <span class="circle"></span>
<div>


CSS-переменные

CSS позволяет задавать переменные (пользовательские свойства) которые можно вставлять вместо конкретных значений стилевых свойств. Переменная создаётся с помощью конструкции --имя-переменной: значение. Использование осуществляется с помошью конструкции var(--имя-переменной). Опционально можно указать и значение по умолчанию которое будет использовано если переменная не определена: var(--имя-переменной, значение по умолчанию).

.example {
    --mycolor: #333;
}

Пользовательские свойства имеют область видимости, они существуют только в пределах указанного селектора и в наследниках попадающих под него. Однако, если задать переменную в специальном псевдоклассе :root, то такая переменная будет считаться глобальной и доступной для любого селектора. Псевдокласс :root по сути является аналогом html – корневого элемента документа, но в стилях имеет ещё более высокий приоритет. Поэтому зачастую используется для хранения глобальных переменных.

В следующем примере используется переменная --size. Её значение задается через атрибут style непосредственно у элементов <span>. В последнем случае ничего не задано, поэтому будет использовано значение по умолчанию.

.kitty {
    height: calc(var(--size, 1) * 15px + 25px);
    width: calc(var(--size, 1) * 15px + 25px);

    border-radius: 50%;
    border: solid 1px #f76b8a;
    margin: 10px;
    background-image: url("kitty.jpg");
    background-size: cover;
}
<div style="display: flex; justify-content: center;">
    <span class="kitty" style="--size: 3"></span>
    <span class="kitty" style="--size: 2"></span>
    <span class="kitty"></span>
</div>



Реализация

Цвета для светлой и тёмной тем будут храниться в виде пользовательских свойств в псевдоклассе :root, что обеспечит к ним доступ из любого места. Данный пседвокласс будет определён дважды. Во втором случае с использованием селектора по пользовательскому атрибуту theme со значением равным dark. Таким образом, когда браузер будет рендрить документ, конкретный цвет для переменных будет определяться наличием или отсутствием данного атрибута у элемента <html>.

В примере ниже при нажатии на кнопку js скрипт будет добавлять, либо удалять theme=dark в тег <html>, тем самым обеспечивая переключение темы для пользователя по его желанию.

base.css

:root {
    --red: black;
    --orange: black;
    --yellow: black;
    --green: black;
    --sky: black;
    --blue: black;
    --purple: black;

    --background-color: #f0f0f0;
}

:root[theme=dark] {
    --red: #ea3546;
    --orange: #f96d00;
    --yellow: #ffc93c;
    --green: #42b883;
    --sky: #00bbf9;
    --blue: #147df5;
    --purple: #f15bb5;

    --background-color: #212121;
}

themes.js

function update()
{
    const theme = "theme";
    let root = document.documentElement;

    if (root.getAttributeNames().includes(theme))
        root.removeAttribute(theme);
    else
        root.setAttribute(theme, "dark");
}

index.html

<html>       <!-- аттрибут theme отсутствует -->
<head>
    <link href="base.css" rel="stylesheet">
    <script src="themes.js"></script>
</head>
<body style="background: var(--background-color)">
    <p>
        <span style="color: var(--red)">Каждый</span>
        <span style="color: var(--orange)">охотник</span>
        <span style="color: var(--yellow)">желает</span>
        <span style="color: var(--green)">знать,</span>
        <span style="color: var(--sky)">где</span>
        <span style="color: var(--blue)">сидит</span>
        <span style="color: var(--purple)">фазан.</span>
    </p>
    <button onClick="update()">Переключить тему</button>
<body>
</html>
Каждыйохотникжелаетзнать,гдесидитфазан.



Хранение пользовательского выбора

При каждом новом входе на сайт, либо открытии новых страниц, выбранная тема будет сбрасываться, поэтому необходимо каким-либо образом хранить пользовательский выбор. Для хранения подобных настроек удобно воспользоваться локальным хранилищем localStorage. Такой подход позволяет работать исключительно на стороне клиента без обращений к серверу и избавит от необходимости постоянно передавать на него куки (cookies). Значения в localStorage хранятся в виде пар ключ=значение, при этом вне зависимости от того, какой тип данных используется, он всегда будет преобразован и записан как строка (string).

Так же, при отсутствии данных о том, что выбрал пользователь (допустим при первом посещении сайта), необходимо установить тему соответствующую текущей в системе.

Ниже представлен алгоритм определения темы которую необходимо представить пользователю:

Алгоритм определения темы

И его реализация:

themes.js

const PCS_DARK    = '(prefers-color-scheme: dark)'; /* медиа-запрос
                                 соответствия тёмной теме */
const STORAGE_KEY = "theme"; /* ключ для хранения настроек, он же
                                 и имя атрибута для <html> */
const THEME_NAME  = ['dark', 'light']; /* возможные названия тем */


/* Определяет, является ли текущая тема в системе "тёмной"? */
const isSystemDarkTheme = () => (window.matchMedia(PCS_DARK).matches);

/* Определяет, необходимо ли пользователю показывать светлую тему? */
const isLightTheme = () => {
    const prefTheme = window.localStorage.getItem(STORAGE_KEY);
    if (typeof prefTheme === 'string')
        return (prefTheme == THEME_NAME[+true]);

    return !isSystemDarkTheme();
}

/* Устанавливает тему в соответствии с текущими настройками пользователя
   либо системы */
const syncTheme = () => {
    isLight = isLightTheme(); /* необходима ли светлая тема? */

    /* в элемент <html> будет добавлен аттрибут theme=dark,
       если необходимо установить тёмную тему, либо он будет
       удален если необходимо установить светлую тему */
    if (isLight)
        document.documentElement.removeAttribute(STORAGE_KEY);
    else
        document.documentElement.setAttribute(STORAGE_KEY, THEME_NAME[+isLight]);
}

/* Принудительно устанавливает для сайта заданную тему, в качестве
   аргумента передается true для светлой и false для тёмной темы */
const setTheme = (isLight) => {
    window.localStorage.setItem(STORAGE_KEY, THEME_NAME[+isLight]);
    syncTheme();
}

Теперь можно обновить код для кнопки, чтобы тема не просто менялась, но и выбор пользователя сохранялся между сессиями.

index.html

<button onClick="setTheme(!isLightTheme())">Переключить тему</button>

Однако при открытии страницы тема сама по себе не будет соответствовать необходимой, поэтому следует осуществить вызов syncTheme(). И сделать это необходимо как можно раньше. Почему раньше? Представим ситуацию, что необходимо переключить тему с той, которая загружается по умолчанию. Соответственно если вызвать syncTheme() когда документ уже загружен хотя бы частично и браузер начал рендринг, получится заметный и неприятный для глаза переход. Допустим на секунду окно будет белым, а потом станет чёрным, либо наоборот. Чтобы не допустить подобное, вызов необходимо сделать до начала рендринга, идеальным будет размещение вызова в блоке <head>. Для этого именно там разместим ссылку на скрипт и добавим в конце вызов syncTheme()

const setTheme = (isLight) => {
    window.localStorage.setItem(STORAGE_KEY, THEME_NAME[+isLight]);
    syncTheme();
}
+
+syncTheme();

Таким образом до момента начала рендринга <html> уже будет верно сконфигурирован под необходимую тему:

<html>
<head>
    <script src="themes.js"></script>
</head>

<body>
    <button onClick="setTheme(!isLightTheme())">Переключить тему</button>
</body>
</html>


Изображения

Переключение изображений можно организовать несколькими способами. Самый очевидный – это исключительно с помощью css и селекторов по атрибуту.

Объявляется пара селекторов: один для изображений светлой темы, а второй для изображений тёмной темы. Каждому из них так же добавляется одноимённая пара с атрибутом темы. Замысел заключается в переключении видимости изображения в зависимости от темы для которой предназначено изображение и текущего выбора пользователя:

/* По умолчанию изображения для светлой 
   темы - отображаются */
.pic_light {
    display: block;
}
/* При выборе пользователем тёмной темы, 
   изображения для светлой - НЕ отображаются */
[theme=dark] .pic_light {
    display: none;
}

/* По умолчанию изображения для тёмной
   темы - НЕ отображаются */
.pic_dark {
    display: none;
}
/* При выборе пользователем светлой темы,
   изображения для тёмной - НЕ отображаются */
[theme=dark] .pic_dark {
    display: block;
}

При следующем объявлении будет отображаться только одно изображение:

<img class="pic_light" src="daring-the-day.jpg">
<img class="pic_dark" src="at-night.jpg">

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


Другой способ заключается в использовании JavaScript. Необходимо написать скрипт, который будет при переключении темы обновлять все изображения. Конкретных способов каким образом это можно сделать крайне много, всё зависит исключительно от фантазии, здесь же рассмотрим пример с <picture> и <source>.

Пусть имеется следующее объявление изображения:

<picture>
    <source media="(prefers-color-scheme: dark)" srcset="at-night.jpg">
    <source media="(prefers-color-scheme: light)" srcset="daring-the-day.jpg">
    <img src="">
</picture>

В отличие от примера выше, здесь в <source> отдельно перечислены оба источника, и для светлой, и для тёмной темы. Подобное определение необходимо в первую очередь для скрипта, а не браузера. Задача скрипта заключается в итерации по всем <pictures> и принудительном указании какое именно изображение необходимо отображать. Достигается это простым путем, в самом верху иерархии динамически можно добавить новый <source> без media атрибута, таким образом подобный источник всегда будет являться приоритетным, именно его srcset и будет использован браузером.

Здесь и далее предполагается, что код добавляется в пример из прошлого раздела. В данном случае дописываем syncTheme:

const syncTheme = () => {
    const MarkSR = 'marked-source';

    /* ... */

    /* Удалим все источники внутри <picture> который добавил
       данный код, они помечены атрибутом с именем записанным
       в константе MarkSR */
    document
        .querySelectorAll(`picture > source[${MarkSR}]`)
        .forEach((el) => el.remove());

    /* Найдем каждый <source> внутри <picture> у которого
       медиа-запрос соответсвует той теме, которую мы сейчас
       хотим установить */
    document
        .querySelectorAll(`picture > source[media*="(prefers-color-scheme: ${THEME_NAME[+isLight]})"]`)
        .forEach((el) => {                /* ...каждый такой источник */
            const cloned = el.cloneNode();   /* клонируем */
            cloned.removeAttribute("media"); /* удаляем у него атрибут media */
            cloned.setAttribute(MarkSR, ''); /* помечаем его */
            el.parentNode.prepend(cloned);   /* добавляем в самое начало */
        });
}

Как результат работы данного кода все <picture> получат новый источник с атрибутом srcset указывающим на изображение текущей темы:

<picture>
    <source srcset="at-night.jpg" marked-source>
    <source media="(prefers-color-scheme: dark)" srcset="at-night.jpg">
    <source media="(prefers-color-scheme: light)" srcset="daring-the-day.jpg">
    <img src="">
</picture>

Поскольку syncTheme для предотвращения морганий вызывается в самом начале, когда ещё невозможно обработать изображения, то при несоответствии текущей темы в системе и выбора пользователя, все изображения так же будут в рассинхронизированом состоянии. Поэтому код для обновления изображений необходимо вызвать по окончании загрузки всего документа.
Для этого в самый конец скрипта необходимо повесить обработчик события onload которое ещё раз вызовет syncTheme:

const setTheme = (isLight) => {
    window.localStorage.setItem(STORAGE_KEY, THEME_NAME[+isLight]);
    syncTheme();
}

syncTheme();
+
+window.onload = () => {
+    syncTheme();
+}


Отслеживание изменений в системе

У многих пользователей в ОС включено автоматические переключение тем в соответствии со временем суток. Хорошим решением будет отслеживание момента такого переключения и автоматической смены темы для соответствия ожиданиям.

window
    .matchMedia(PCS_DARK)
    .addEventListener('change', ({matches:isDark}) => setTheme(!isDark))

Данный код можно добавить в самый конец скрипта.


Заголовок окна браузера

Браузер Safari и ряд мобильных браузеров поддерживают возможность задания цвета заголовка окна. Достигается это с помощью тега <meta> и с атрибутом name равным theme-color. Значение цвета задается в атрибуте content.

Необходимо задать цвета для заголовка с помощью переменных:

:root {
    --title-color: green;
}

:root[theme=dark] {
    --title-color: #872e4e;
}

В секцию <head> добавить:

<meta name="theme-color" content="">

И следующий код в тело syncTheme:

var style = getComputedStyle(document.documentElement);
let themeMeta = document.querySelector('meta[name="theme-color"]');
if (themeMeta) {
    themeMeta.content = style.getPropertyValue('--title-color');
}

Стоит иметь в виду, что могут присутствовать различные ограничения на свободу выбора цвета. Как пример, в настольной версии браузера Safari невозможно задать ряд красных оттенков когда в системе выбрана светлая тема, это сделано для предотвращения визуального слияния с кнопкой закрытия окна. А при действии тёмной темы, в целом невозможно задать слишком светлые цвета, они автоматически будут заменены на серый.


Дополнительно

Чёрно-белые изображения

В отдельном ряду стоят чёрно-белые изображения, не обязательно создавать для них отдельную инвертированную версию, можно воспользоваться фильтрами:

.inverted_picture {
    filter: invert(1);
}
[theme=dark] .inverted_picture {
    filter: invert(0.095);
}
<img src="campfire.png" class="inverted_picture">

Изображение ниже в оригинале имеет чёрный фон, поэтому для светлой темы оно полностью инвертируется invert(1), а для тёмной темы лишь незначительно осветляется для соответствия серому фону.

для просмотра оригинала
наведите курсор мыши


SVG-изображения

SVG изображения позволяют использовать CSS-переменные, но для этого необходимо либо чтобы файл со стилями был напрямую встроен внутри SVG изображения, либо само изображение было непосредственно встроено на страницу как в следующем примере. Так же не стоит забывать о возможности использования ключевого слова currentColor, которое позволяет использовать текущее значение из свойства color.

:root {
    --trunk: #583400;
    --body: #4f7a28;
    --leaves: #77bb41;
}

[theme=dark]:root {
    --trunk: #05404c;
    --body: #00757F;
    --leaves: #5CB087;
}
<body>
    <svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
        <path fill="var(--body)" d="M637.69 789c46.82 ... 82.34-40.88z"></path>
        <path fill="var(--leaves)" d="M646 604.6c5.32 ... 17.31 22.09z"></path>
        <path fill="var(--trunk)" d="M515.53 671.23s2 ... 14.06 14.06z"></path>
    </svg>
</body>



Элементы управления

Если сайт использует стандартные элементы управления, то обязательно следует указать цветовую схему и для них. Для этого используется специальное свойство color-scheme которое может принимать значения light или dark:

<div style="color-scheme: light">
    Светлая тема:
    <input value="Поле ввода">&nbsp;
    <button>Кнопочка</button>
</div>
<div style="color-scheme: dark">
    Тёмная тема:
    <input value="Поле ввода"></input>&nbsp;
    <button>Кнопочка</button>
</div>
Светлая тема:   
Тёмная тема:    


Если задать значение для color-scheme в light dark, появляется возможность использовать экспериментальную функцию light-dark(), она принимает два аргумента из значений цветов для светлой и тёмной тем, а возвращает соответствующее текущим настройкам системы/браузера.

Пример: color: light-dark(black, white);


На момент написания заметки функциональность присутствовала только в браузере Firefox. Текущий статус поддержки браузерами можно посмотреть здесь.


Плавное переключение цветов

При определённых условиях мгновенное переключение темы с тёмной на светлую может быть крайне неприятно для глаз пользователя. Хорошим решением будет добавить плавную анимацию перехода. Однако не стоит забывать про устройства которые не поддерживают быстрое обновление экрана (update: slow), либо у пользователя включена опция снижения анимаций (prefers-reduced-motion):

@media screen and
  (prefers-reduced-motion: no-preference),
  (update: fast) {
  * {
      transition-property: background-color, border-color, fill, stroke;
      transition-duration: 1.3s;
  }
}

Итог

Рассмотренный код и большую часть приёмов можно скачать здесь:

ФайлMD5
theme-toggle.zip9573441af33b9f1711f6b741adb4f5cf


На этом всё.