Создание компонента переключения темы

Базовый обзор того, как создать адаптивный и доступный компонент переключения тем.

В этом посте я хочу поделиться мыслями о том, как создать компонент переключения темной и светлой темы. Попробуйте демо .

Размер кнопки демо увеличен для удобства просмотра.

Если вы предпочитаете видео, вот версия этого поста на YouTube:

Обзор

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

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

На диаграмме показан предварительный просмотр событий загрузки страницы JavaScript и взаимодействия с документом, чтобы в целом показать, что существует 4 способа настройки темы.

Разметка

Для переключения следует использовать <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 для элемента добавьте высоту и ширину по тем же причинам, по которым изображения должны иметь встроенные размеры .

Солнце

Значок солнца с солнечными лучами исчез, а ярко-розовая стрелка указывает на круг в центре.

Изображение солнца состоит из круга и линий, для которых в 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>

Кроме того, свойство маски указывает на идентификатор элемента SVG , который вы создадите дальше, и, наконец, задает цвет заливки, соответствующий цвету текста страницы с помощью currentColor .

Солнечные лучи

Значок солнца, центр которого исчез, и ярко-розовая стрелка, указывающая на солнечные лучи.

Затем линии солнечных лучей добавляются чуть ниже круга, внутри группового элемента <g> group.

<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 не загружался, чтобы убедиться, что результат не слишком велик и не вызывает проблем с макетом. Встроенные атрибуты высоты и ширины в SVG, а также использование currentColor дают минимальные правила стиля, которые браузер может использовать, если CSS не загружается. Это создает хорошие защитные стили против сетевой турбулентности.

Макет

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

Светлая тема

ALT_TEXT_ЗДЕСЬ

Чтобы анимация масштабирования и поворота происходила из центра фигур 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);
    }
  }
}

Темная тема

ALT_TEXT_ЗДЕСЬ

Стили луны должны удалить солнечные лучи, увеличить солнечный круг и переместить маску круга.

.sun-and-moon {
  @nest [data-theme="dark"] & {
    & > .sun {
      transform: scale(1.75);
    }

    & > .sun-beams {
      opacity: 0;
    }

    & > .moon > circle {
      transform: translateX(-7px);

      @supports (cx: 1) {
        transform: translateX(0);
        cx: 17;
      }
    }
  }
}

Обратите внимание, что темная тема не имеет изменений цвета или переходов. Компонент родительской кнопки владеет цветами, причем они уже адаптивны в темном и светлом контексте. Информация о переходе должна находиться в основе медиа-запроса пользователя о предпочтениях движения.

Анимация

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

Совместное использование медиа-запросов и импорт плавности

Чтобы упростить размещение переходов и анимации в соответствии с предпочтениями движения операционной системы пользователя, плагин 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: 1) {
        transform: translateX(0);
        cx: 17;
        transition: cx .25s var(--ease-out-5);
      }
    }

    @nest [data-theme="dark"] & {
      & > .moon > circle {
        transition-delay: .25s;
        transition-duration: .5s;
      }
    }
  }
}
Переход от светлого к темному
Переход от темного к светлому

Предпочитает ограниченное движение

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

JavaScript

В этом компоненте много работы по JavaScript: от управления информацией ARIA для программ чтения с экрана до получения и установки значений из локального хранилища .

Опыт загрузки страницы

Было важно, чтобы при загрузке страницы не происходило мигания цвета. Если пользователь с темной цветовой схемой указывает, что он предпочитает светлую цветовую схему с этим компонентом, а затем перезагрузил страницу, сначала страница будет темной, а затем начнет светиться. Чтобы предотвратить это, нужно было выполнить небольшую блокировку JavaScript с целью как можно раньше установить data-theme атрибута HTML.

<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 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()
  })
Изменение системных настроек MacOS меняет состояние переключения темы.

Заключение

Теперь, когда вы знаете, как я это сделал, как бы вы‽ 🙂

Давайте разнообразим наши подходы и изучим все способы разработки в Интернете. Создайте демо, пришлите мне ссылку в Твиттере , и я добавлю ее в раздел ремиксов сообщества ниже!

Ремиксы сообщества