Базовый обзор того, как создать адаптивный и доступный компонент переключения тем.
В этой публикации я хочу поделиться мыслями о создании компонента переключения между тёмной и светлой темами. Попробуйте демо .
Если вы предпочитаете видео, вот версия этого поста на YouTube:
Обзор
Веб-сайт может предоставлять настройки для управления цветовой схемой, не полагаясь исключительно на системные настройки. Это означает, что пользователи могут просматривать веб-страницы в режиме, отличном от системных настроек. Например, система пользователя использует светлую тему, но пользователь предпочитает, чтобы веб-сайт отображался в тёмной теме.
При разработке этой функции необходимо учитывать ряд аспектов веб-инженерии. Например, браузер должен быть уведомлён о настройках как можно раньше, чтобы предотвратить мерцание цветов на странице, а элемент управления должен сначала синхронизироваться с системой, а затем разрешать сохранённые исключения на стороне клиента.

Разметка
 Для переключения следует использовать <button> , поскольку в этом случае вы сможете воспользоваться преимуществами событий и функций взаимодействия, предоставляемых браузером, такими как события щелчков и фокусируемость.
Кнопка
 Кнопке требуется класс для использования из CSS и идентификатор для использования из JavaScript. Кроме того, поскольку содержимое кнопки представляет собой значок, а не текст, добавьте атрибут title , чтобы сообщить о её назначении. Наконец, добавьте [aria-label] для хранения состояния кнопки-значка, чтобы программы чтения с экрана могли сообщать о состоянии темы людям с нарушениями зрения.
<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto"
>
  …
</button>
 aria-label и aria-live вежливые
 Чтобы указать программам чтения с экрана, что изменения в aria-label следует объявлять, добавьте к кнопке aria-live="polite" .
<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto" 
  aria-live="polite"
>
  …
</button>
 Это добавление разметки сигнализирует программам чтения с экрана о необходимости вежливо сообщать пользователю об изменении, вместо aria-live="assertive" . В случае этой кнопки будет объявлено «светлый» или «темный» в зависимости от того, какой стала aria-label .
Значок масштабируемой векторной графики (SVG)
SVG позволяет создавать высококачественные масштабируемые фигуры с минимальным объёмом разметки. Взаимодействие с кнопкой может вызывать новые визуальные состояния векторов, что делает SVG идеальным инструментом для создания значков.
 Следующая разметка SVG помещается внутрь <button> :
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  …
</svg>
 К элементу SVG добавлен атрибут aria-hidden , чтобы программы чтения с экрана знали, что его следует игнорировать, поскольку он помечен как презентационный. Это отлично подходит для визуального оформления, например, для значка внутри кнопки. В дополнение к обязательному атрибуту viewBox у элемента добавьте атрибуты height и width по тем же причинам, по которым изображениям следует присваивать встроенные размеры .
Солнце
![]()
 Изображение солнца состоит из круга и линий, для которых в SVG удобно предусмотрены формы. Центрирование <circle> осуществляется установкой свойств cx и cy на значение 12, что составляет половину размера области просмотра (24), а затем радиусом ( r ) 6 , который задаёт размер.
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
</svg>
 Кроме того, свойство mask указывает на идентификатор элемента SVG , который вы создадите далее, и, наконец, задаст цвет заливки, который соответствует цвету текста страницы с помощью currentColor .
Лучи солнца
![]()
 Затем линии солнечных лучей добавляются прямо под кругом, внутри группового элемента <g> .
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
  <g class="sun-beams" stroke="currentColor">
    <line x1="12" y1="1" x2="12" y2="3" />
    <line x1="12" y1="21" x2="12" y2="23" />
    <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
    <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
    <line x1="1" y1="12" x2="3" y2="12" />
    <line x1="21" y1="12" x2="23" y2="12" />
    <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
    <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
  </g>
</svg>
 На этот раз вместо значения fill , равного currentColor , задаётся обводка каждой линии. Линии и окружности создают красивое солнце с лучами.
Луна
Чтобы создать иллюзию плавного перехода между светом (солнцем) и тьмой (луной), луна представляет собой расширение значка солнца с использованием маски SVG.
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
  <circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
  <g class="sun-beams" stroke="currentColor">
    …
  </g>
  <mask class="moon" id="moon-mask">
    <rect x="0" y="0" width="100%" height="100%" fill="white" />
    <circle cx="24" cy="10" r="6" fill="black" />
  </mask>
</svg>

 Маски с SVG очень эффективны, позволяя белым и чёрным цветам как удалять, так и включать части другого графического объекта. Значок солнца будет скрыт <circle> луны с SVG-маской, если просто переместить круг в область маски и обратно.
Что произойдет, если CSS не загрузится?

 Может быть полезно протестировать SVG-файл, как если бы CSS не загружался, чтобы убедиться, что результат не слишком большой и не создаст проблем с версткой. Встроенные атрибуты height и width в SVG, а также использование currentColor , задают минимальные правила стилей, которые браузер будет применять, если CSS не загружается. Это обеспечивает отличную защиту стилей от сетевых сбоев.
Макет
Компонент переключения темы занимает небольшую площадь, поэтому для макета не нужны сетки или flexbox. Вместо этого используются позиционирование SVG и CSS-преобразования.
Стили
 .theme-toggle стили
 Элемент <button> — это контейнер для форм и стилей значков. Этот родительский контекст будет содержать адаптивные цвета и размеры для передачи в SVG.
Первая задача — сделать кнопку круглой и удалить стили кнопок по умолчанию:
.theme-toggle {
  --size: 2rem;
  
  background: none;
  border: none;
  padding: 0;
  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;
}
 Затем добавьте стили взаимодействия. Добавьте стиль курсора для использования мыши. Добавьте touch-action: manipulation для быстрого сенсорного взаимодействия . Удалите полупрозрачную подсветку, применяемую iOS к кнопкам. Наконец, дайте контуру состояния фокуса немного свободного пространства от края элемента:
.theme-toggle {
  --size: 2rem;
  background: none;
  border: none;
  padding: 0;
  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;
  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  outline-offset: 5px;
}
SVG-изображение внутри кнопки также нуждается в стилизации. Оно должно соответствовать размеру кнопки и, для визуальной мягкости, скруглять концы линий:
.theme-toggle {
  --size: 2rem;
  background: none;
  border: none;
  padding: 0;
  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1;
  border-radius: 50%;
  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  outline-offset: 5px;
  & > svg {
    inline-size: 100%;
    block-size: 100%;
    stroke-linecap: round;
  }
}
 Адаптивное изменение размера с помощью запроса наведения hover
 Размер кнопки-значка немного маловат ( 2rem , что приемлемо для пользователей мыши, но может быть проблематично для пользователя с грубым указателем, например, пальцем. Чтобы кнопка соответствовала многочисленным рекомендациям по размеру сенсорного экрана, используйте медиазапрос при наведении курсора, чтобы увеличить размер. 
.theme-toggle {
  --size: 2rem;
  …
  
  @media (hover: none) {
    --size: 48px;
  }
}
Стили SVG солнца и луны
Кнопка содержит интерактивные элементы компонента переключения темы, а SVG внутри отвечает за визуальные и анимированные элементы. Именно здесь можно сделать значок красивым и оживить его.
Светлая тема

 Чтобы анимация масштабирования и поворота выполнялась относительно центра SVG-фигур, установите для них transform-origin: center center . В данном случае фигуры используют адаптивные цвета, предоставляемые кнопкой. Для заливки луны и солнца используются переменные var(--icon-fill) и var(--icon-fill-hover) . 
.sun-and-moon {
  & > :is(.moon, .sun, .sun-beams) {
    transform-origin: center center;
  }
  & > :is(.moon, .sun) {
    fill: var(--icon-fill);
    @nest .theme-toggle:is(:hover, :focus-visible) > & {
      fill: var(--icon-fill-hover);
    }
  }
  & > .sun-beams {
    stroke: var(--icon-fill);
    stroke-width: 2px;
    @nest .theme-toggle:is(:hover, :focus-visible) & {
      stroke: var(--icon-fill-hover);
    }
  }
}
Темная тема

В стилях Луны необходимо удалить солнечные лучи, увеличить масштаб солнечного круга и переместить маску круга.
.sun-and-moon {
  @nest [data-theme="dark"] & {
    & > .sun {
      transform: scale(1.75);
    }
    & > .sun-beams {
      opacity: 0;
    }
    & > .moon > circle {
      transform: translateX(-7px);
      @supports (cx: 1px) {
        transform: translateX(0);
        cx: 17px;
      }
    }
  }
}
Обратите внимание, что в тёмной теме нет изменений цветов или переходов. Цвета принадлежат родительскому компоненту кнопки, который уже адаптивен в тёмном и светлом контексте. Информация о переходах должна быть реализована в медиазапросе настроек движения пользователя.
Анимация
Кнопка должна быть функциональной и иметь состояние, но на данном этапе без переходов. В следующих разделах мы расскажем, как и какие переходы будут происходить.
Обмен медиа-запросами и импорт изменений
Чтобы упростить добавление переходов и анимации в настройки движений операционной системы пользователя, плагин PostCSS Custom Media позволяет использовать черновой вариант спецификации CSS для синтаксиса переменных медиа-запросов :
@custom-media --motionOK (prefers-reduced-motion: no-preference);
/* usage example */
@media (--motionOK) {
  .sun {
    transition: transform .5s var(--ease-elastic-3);
  }
}
Для уникальных и простых в использовании CSS-стилей импортируйте раздел « Стилей» из Open Props :
@import "https://unpkg.com/open-props/easings.min.css";
/* usage example */
.sun {
  transition: transform .5s var(--ease-elastic-3);
}
Солнце
Переходы солнца будут более игривыми, чем у луны, что достигается с помощью эффекта «пружинящего» сглаживания. Солнечные лучи должны слегка подпрыгивать при вращении, а центр солнца должен слегка подпрыгивать при масштабировании.
Стили по умолчанию (светлая тема) определяют переходы, а стили темной темы определяют настройки перехода к светлой:
.sun-and-moon {
  @media (--motionOK) {
    & > .sun {
      transition: transform .5s var(--ease-elastic-3);
    }
    & > .sun-beams {
      transition: 
        transform .5s var(--ease-elastic-4),
        opacity .5s var(--ease-3)
      ;
    }
    @nest [data-theme="dark"] & {
      & > .sun {
        transform: scale(1.75);
        transition-timing-function: var(--ease-3);
        transition-duration: .25s;
      }
      & > .sun-beams {
        transform: rotateZ(-25deg);
        transition-duration: .15s;
      }
    }
  }
}
На панели «Анимация» в Chrome DevTools вы увидите временную шкалу для анимационных переходов. Можно просмотреть общую продолжительность анимации, отдельных элементов и время перехода.


Луна
 Положения света и тени луны уже установлены, добавьте стили перехода внутри медиа-запроса --motionOK , чтобы оживить его, учитывая предпочтения пользователя в отношении движения. 
Время задержки и продолжительность имеют решающее значение для обеспечения плавности этого перехода. Например, если солнечное затмение произойдёт слишком рано, переход не будет ощущаться организованным и игривым, а скорее хаотичным.
.sun-and-moon {
  @media (--motionOK) {
    & .moon > circle {
      transform: translateX(-7px);
      transition: transform .25s var(--ease-out-5);
      @supports (cx: 1px) {
        transform: translateX(0);
        cx: 17px;
        transition: cx .25s var(--ease-out-5);
      }
    }
    @nest [data-theme="dark"] & {
      & > .moon > circle {
        transition-delay: .25s;
        transition-duration: .5s;
      }
    }
  }
}


Предпочитает меньше движения
В большинстве GUI-испытаний я стараюсь сохранять некоторую анимацию, например, плавное изменение прозрачности, для пользователей, предпочитающих минимальные движения. Однако этот компонент работал лучше с мгновенным изменением состояния.
JavaScript
В этом компоненте JavaScript выполняет большую работу: от управления информацией ARIA для программ чтения с экрана до получения и установки значений из локального хранилища .
Процесс загрузки страницы
 Было важно исключить мерцание цветов при загрузке страницы. Если пользователь с тёмной цветовой схемой укажет, что предпочитает светлую, с помощью этого компонента, а затем перезагрузит страницу, сначала страница будет тёмной, а затем станет светлой. Чтобы предотвратить это, пришлось запустить небольшой блокирующий JavaScript-код, чтобы как можно раньше установить HTML-атрибут data-theme . 
<script src="./theme-toggle.js"></script>
Для этого в документе <head> сначала загружается простой тег <script> , до любой разметки CSS или <body> . Когда браузер обнаруживает такой немаркированный скрипт, он запускает его код и выполняет его до выполнения остальной части HTML-кода. Экономно используя этот момент блокировки, можно установить атрибут HTML до того, как основной CSS-код отрисует страницу, предотвращая появление мерцания или искажения цветов.
Сначала JavaScript проверяет предпочтения пользователя в локальном хранилище и возвращается к проверке системных настроек, если в хранилище ничего не найдено:
const storageKey = 'theme-preference'
const getColorPreference = () => {
  if (localStorage.getItem(storageKey))
    return localStorage.getItem(storageKey)
  else
    return window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark'
      : 'light'
}
Далее анализируется функция для установки предпочтений пользователя в локальном хранилище:
const setPreference = () => {
  localStorage.setItem(storageKey, theme.value)
  reflectPreference()
}
Далее следует функция изменения документа с учетом предпочтений.
const reflectPreference = () => {
  document.firstElementChild
    .setAttribute('data-theme', theme.value)
  document
    .querySelector('#theme-toggle')
    ?.setAttribute('aria-label', theme.value)
}
На этом этапе важно отметить состояние разбора HTML-документа. Браузер пока не знает о кнопке "#theme-toggle", поскольку тег <head> не был полностью проанализирован. Однако в браузере есть document.firstElementChild , также известный как тег <html> . Функция пытается установить оба тега для синхронизации, но при первом запуске сможет установить только HTML-тег. querySelector сначала ничего не находит, а необязательный оператор цепочки гарантирует отсутствие синтаксических ошибок в случае, если он не найден, и происходит попытка вызова функции setAttribute.
 Затем функция reflectPreference() вызывается немедленно, поэтому для HTML-документа устанавливается атрибут data-theme : 
reflectPreference()
Кнопке по-прежнему нужен атрибут, поэтому дождитесь события загрузки страницы, затем можно будет безопасно выполнять запросы, добавлять прослушиватели и задавать атрибуты:
window.onload = () => {
  // set on load so screen readers can get the latest value on the button
  reflectPreference()
  // now this script can find and listen for clicks on the control
  document
    .querySelector('#theme-toggle')
    .addEventListener('click', onClick)
}
Опыт переключения
При нажатии кнопки тема должна быть изменена в памяти JavaScript и в документе. Необходимо проверить текущее значение темы и принять решение о её новом состоянии. После установки нового состояния сохраните его и обновите документ:
const onClick = () => {
  theme.value = theme.value === 'light'
    ? 'dark'
    : 'light'
  setPreference()
}
Синхронизация с системой
Уникальной особенностью этого переключения темы является синхронизация с системными настройками при их изменении. Если пользователь изменяет системные настройки, когда страница и этот компонент видны, переключение темы изменится в соответствии с новыми настройками пользователя, как если бы пользователь взаимодействовал с переключением темы одновременно с переключением системы.
 Достигните этого с помощью JavaScript и события matchMedia , прослушивающего изменения в медиа-запросе: 
window
  .matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', ({matches:isDark}) => {
    theme.value = isDark ? 'dark' : 'light'
    setPreference()
  })
Заключение
Теперь, когда вы знаете, как я это сделал, как бы вы поступили? 🙂
Давайте разнообразим наши подходы и изучим все способы разработки в интернете. Создайте демо, пришлите мне ссылку в Твиттер , и я добавлю её в раздел ремиксов сообщества ниже!