Данный гайд описывает один из возможных подходов к организации фичи темизации приложения.
Глобально статья поделена на 2 части:
База
Здесь мы опишем логику работы с темами, которая построена на чистом css (ну почти). Но этот механизм не имеет никаких "завязок" на js фреймворк/библиотеку.
React
В этой части мы посмотрим, как именно в контексте react приложения мы сможем эффеективно применять наши темы.
Что хотим получить
Предет тем, как приступим к решение задачи темизации нашего react приложения сформулируем возможности, которые должна обладать наша будущая система.
Возможность расширения
Мы не должны быть ограничены только одной цветовой темой. И нам не должно быть мучительно больно, если наши старшие братья по интерфейсам (дизайнеры) вдруг запилят к стандартным "светлой" и "тёмной" темам ещё и "малиновую, по случаю дня рождения компании".
Возможность изменить стилизацию приложения (css).
Это в общем то и есть основная цель - мы должны уметь легко применять цвета выбранной темы на наши элементы в css.
Возможность изменить стилизацию приложения (js)
Части наших элементов, может понадобится изменять своё отображение не только на основе css но и на основе свойств, которые мы пробрасываем внутрь компонентов. Например, так работают большинство библиотек элементов (закрыты к изменению, но имеют api для управления).
Следовательно где-то в коде уже наших компонентов мы должны иметь возможность получить значение текущей темы и работать с ним.
Возможность из любого места приложения инициировать изменение темы
В любой момент развития нашего проекта/продукта может изменится дизайн концепция и/или могут тестироваться различные гипотезы, которые могут привести к изменению нашего ui. Компоненты могут меняться местами, элементы управления уезжать "вглубь" или в порталы/модальные окна и т.д.
Для того, чтобы в реализации учесть эти возможные изменения мы должны зафиксировать требование - метод для смены темы должен быть внутри наших компонентов.
При этом мы заранее не можем знать, на какой "глубине/уровне вложенности" этот элемент управления темой может появится.
Кажется теперь, когда мы понимаем все требования к нашей системе, можно переходить к проектированию решения.
P.S.:
В рамках нашего скоупа обсуждения, мы не будем затрагивать вопросы всей дизайн системы, а сосредоточимся только на цветовых темах.
План действий
Примем решение о том, за счёт чего мы будем переключать цветовую тему
Определим где и как будем описывать связки цветов. Так же опишем механизм их применения в css
Сделаем тему доступной внутри react компонентов и определим, как именно мы дадим нашим компонентам возможность тригерить смену цветовой темы
Решения
Переключение темы
Решение:
data-theme атрибут для установки темы
Подробнее:
Переключать тему мы будем очень просто - изменяя содержимое data-theme атрибута на рутовом элементе (html).
document.documentElement.setAttribute('data-theme', theme);
Значением theme будет строка, являющаяся названием темы: dark | light;
Это максимально простое решение, которое требует минимум действий и так же позволяет нам опираться на каскад css для применения стилей конкретной темы на любом элементе нашей страницы.
Связки цветов
Решение:
триады для описание цвета
[data-theme='light'] слектор по аттрибуту и custom properties для применения в css.
Подробнее:
Цвета для каждой темы будем описывать триадами (сетами по 3 цифры, которые кодируют цвет).
Это удобно для того, чтобы иметь возможность применять прозрачность (устанавливать значение альфа канала) для цветов.
А для применения этих триад будем использовать custom properties. Они широко распространены и используются в том же CRA из коробки.
Т.е. одну и ту же переменную --some-color: 255, 255, 255 мы сможем использовать в двух вариантах: rgb и rgba
При этом нам надо иметь универсальный способ "доставки" значения цвета конкретной темы в тот кусочек css, который мы хотим стилизовать. Для этого будем использовать селектор по аттрибуту, который мы установили в предыдущем пункте, и внутри будем описывать уже переменные:
[data-theme='light'] {
/* some vars here */
}
Итого, общая связка "объявить тему и сделать её доступной в css" будет выглядеть следующим образом:
:root {
/* light theme tokens */
--background-primary-light: 44, 191, 170;
--accent-light: 37, 89, 88;
/* dark theme tokens */
--background-primary-dark: 25, 48, 66;
--accent-dark: 90, 182, 204;
}
/* map tokens to proper theme */
[data-theme='light'] {
--background-primary: var(--background-primary-light);
--accent: var(--accent-light);
}
[data-theme='dark'] {
--background-primary: var(--background-primary-dark);
--accent: var(--accent-dark);
}
А непосредственное применение значений этих перменных в css делается в 2 шага:
Один раз импортируем файл с токенами в рутовый компонент своего приложения:
import './components/theme-provider/themes.css';
Используем синтаксис custom properties для стилизации цветов:
.class {
color: rgb(var(--accent));
background-color: rgba(var(--background-primary), 0.5);
}
Таким образом реализованы базовые требования к нашей системе:
Возможность изменить стилизацию приложения (css) - просто используем наши custom properties
Возможность расширения - мы можем создать неограниченное большое количество цветовых тем.
Вот и всё, теперь мы имеем рабочий css механизм переключения цветовой темы. Это база, которая по сути не привязана ни к какому фреймворку/библиотеке и прочим вещам. Только css и его сборка.
Применение и изменение темы в react приложении.
Решение:
localStorage для сохранения значения темы
createContext react контекст, для получения доступа к теме и её изменению на любом уровне вложенности
useTheme кастомный хук, для упрощения доступа к контексту
Подробнее:
Тему надо бы уметь сохранять/получать между перезагрузкой страниц.
const StorageKey = 'features-color-theme';
const getTheme = (): Themes => {
let theme = localStorage.getItem(StorageKey);
if (!theme) {
localStorage.setItem(StorageKey, 'light');
theme = 'light';
}
return theme as Themes;
};
Теперь объявим доступные темы и сам контекст:
const supportedThemes = {
light: 'light',
dark: 'dark',
};
type Themes = keyof typeof supportedThemes;
const ThemeContext = createContext<
| {
theme: Themes;
setTheme: (theme: Themes) => void;
supportedThemes: { [key: string]: string };
}
| undefined
>(undefined);
PS: Подробнее про keyof typeof
После этого мы должны создать сам компонент, который и будет "прокидывать" контекст вниз для всех своих children
const Theme = (props: { children: React.ReactNode }) => {
const [theme, setTheme] = useState<Themes>(getTheme);
return (
<ThemeContext.Provider
value={{
theme,
setTheme,
supportedThemes,
}}
>
{props.children}
</ThemeContext.Provider>
);
};
И не забудем написать кусочек кода, который сохраняет нам значение в нашем хранилище (localStorage)
const [theme, setTheme] = useState<Themes>(getTheme);
useEffect(() => {
localStorage.setItem(StorageKey, theme);
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
Вроде бы всё работает, осталось собрать всё воедино.
Однако перед этим давайте попробуем упростить себе жизнь в будущем и сделаем стандартный контрол, который будет изменять значение темы. Назовём его SimpleToggler.
function SimpleToggler() {
const { theme, setTheme } = useTheme();
const handleSwitchTheme = () => {
if (theme === 'dark') {
setTheme('light');
} else {
setTheme('dark');
}
};
return (
<div className={Styles.simpleToggler} onClick={handleSwitchTheme}>
<div className={Styles.ball} data-theme={theme} />
</div>
);
}
:root {
--toggler-padding: 3px;
--toggler-border: 2px;
--ball-diameter: 14px;
--toggler-width: 47px;
}
.simpleToggler {
display: flex;
width: var(--toggler-width);
border-radius: var(--toggler-width);
padding: var(--toggler-padding);
background-color: rgb(var(--background-primary));
border: var(--toggler-border) solid rgb(var(--accent));
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
position: relative;
cursor: pointer;
transition: backgroundColor 0.2s ease;
}
.ball {
position: relative;
z-index: 1;
width: var(--ball-diameter);
height: var(--ball-diameter);
background-color: rgb(var(--background-primary));
background-position: center;
background-size: cover;
border-radius: 50%;
transition: transform 0.2s linear, backgroundColor 0.2s ease;
}
.ball[data-theme='dark'] {
background-image: url('./images/moon.png');
}
.ball[data-theme='light'] {
background-image: url('./images/sun.png');
}
html[data-theme='light'] .simpleToggler {
transform: translateX(0);
}
html[data-theme='dark'] .ball {
transform: translateX(
calc(
var(--toggler-width) - var(--ball-diameter) - 4 * var(--toggler-padding)
)
);
}
И для того, чтобы не "размазывать" логику по разным файлам мы применим подход Compound components.
Итак, собираем всё вместе:
import React, { useEffect, createContext, useState, useContext } from 'react';
import Styles from './index.module.css';
const StorageKey = 'features-color-theme';
const supportedThemes = {
light: 'light',
dark: 'dark',
};
type Themes = keyof typeof supportedThemes;
const ThemeContext = createContext<
| {
theme: Themes;
setTheme: (theme: Themes) => void;
supportedThemes: { [key: string]: string };
}
| undefined
>(undefined);
const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error(
'You can use "useTheme" hook only within a <ThemeProvider> component.'
);
}
return context;
};
const getTheme = (): Themes => {
let theme = localStorage.getItem(StorageKey);
if (!theme) {
localStorage.setItem(StorageKey, 'light');
theme = 'light';
}
return theme as Themes;
};
const Theme = (props: { children: React.ReactNode }) => {
const [theme, setTheme] = useState<Themes>(getTheme);
useEffect(() => {
localStorage.setItem(StorageKey, theme);
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
return (
<ThemeContext.Provider
value={{
theme,
setTheme,
supportedThemes,
}}
>
{props.children}
</ThemeContext.Provider>
);
};
Theme.SimpleToggler = function SimpleToggler() {
const { theme, setTheme } = useTheme();
const handleSwitchTheme = () => {
if (theme === 'dark') {
setTheme('light');
} else {
setTheme('dark');
}
};
return (
<div className={Styles.simpleToggler} onClick={handleSwitchTheme}>
<div className={Styles.ball} data-theme={theme} />
</div>
);
};
export { useTheme };
export default Theme;
Применяем то, что получилось
Нам нужно обернуть в наш провайдер главный компонент приложения:
import React from 'react';
import ReactDOM from 'react-dom/client';
import Theme from './components/theme-provider';
import App from './pages';
import './index.css';
import './components/theme-provider/themes.css';
const root = document.getElementById('root') as HTMLElement;
ReactDOM.createRoot(root).render(
<Theme>
<App />
</Theme>
);
А в самих компонентах можем уже использовать то, что нам больше нравится и/или нужно:
Использовать готовый компонент simpleToggler
import Theme from './components/theme-provider';
import Styles from './index.module.css';
const Component = () => {
return (
<div className={Styles.wrapper}>
<Theme.SimpleToggler />
</div>
);
};
Или использовать хук useTheme:
import { useTheme } from './components/theme-provider';
import Styles from './index.module.css';
const option_1 = '1';
const option_2 = '2';
const Component = () => {
const { theme } = useTheme();
const value = theme === 'dark' ? option_1 : option_2;
return (
<div className={Styles.wrapper}>
{/* some markup that use selected theme value */}
</div>
);
};
Итого
Получили в итоге гибкую систему, которая отвечает всем изначально поставленным требованиям:
Имеем возможность расширения
описываем токены, собираем из них тему, используем
Имеем возможность изменять тему (css/js)
css переменные делают цвета темы доступной;
кастомный хук позволяет настраивать внешние компоненты по нашим потребностям
Имеем возможность настраивать тему откуда угодно
используем контекст для этого
Summary по решениям:
Переключение темы
data-theme аттрибут для установки темы
Связки цветов
триады для описание цвета
[data-theme='light'] слектор по аттрибуту и custom properties для применения в css.
Применение и изменение темы в react приложения.
localStorage для сохранения значения темы
createContext react контекст, для получения доступа к теме и её изменению на любом уровне вложенности
useTheme кастомный хук, для упрощения доступа к контексту
Спасибо за чтение и удачи в темизации ваших приложений)
PS:
Ссылки из статьи: