Дизайн-токены и темизация
Три уровня переменных, из-за которых третья тема добавляется без единой правки в компонентах. И почему --color-danger лучше, чем --red-500.
Что ты научишься делать
- Разложишь переменные на три уровня и поймёшь, зачем это нужно
- Добавишь третью тему, не тронув ни одного компонента
- Уберёшь мигание темы при загрузке страницы
#Проблема появляется на второй теме
С одной темой переменные кажутся излишеством: ну да, --purple: #6544e0 вместо цвета в двадцати местах. Удобно, но не революция.
Разница видна, когда просят тёмную тему. И становится болезненной, когда после неё просят контрастную — для слабовидящих.
Если компоненты ссылаются на --purple, в тёмной теме придётся переопределить --purple на не-фиолетовый. Имя начинает врать. Дальше кто-то заводит --purple-dark, и через месяц никто не понимает, какую переменную брать.
#Три уровня
Решение — разделить переменные по слоям. Каждый следующий ссылается на предыдущий.
| Уровень | Что содержит | Пример |
|---|---|---|
| Сырые значения | Палитра. Названия про цвет, не про смысл | --violet-600: #6544e0 |
| Семантические | Роль в интерфейсе. Меняются от темы к теме | --color-accent: var(--violet-600) |
| Компонентные | Точка настройки конкретного блока | --btn-bg: var(--color-accent) |
/* Уровень 1: палитра. Одна на весь проект, темы её не трогают */:root { --violet-600: #6544e0; --violet-300: #b9a5ff; --red-600: #c8394a; --gray-900: #16181d; --gray-100: #f4f4f8; --white: #fff;}/* Уровень 2: смысл. Вот это и переключают темы */:root,[data-theme="light"] { --color-surface: var(--white); --color-text: var(--gray-900); --color-accent: var(--violet-600); --color-danger: var(--red-600);}[data-theme="dark"] { --color-surface: var(--gray-900); --color-text: var(--gray-100); --color-accent: var(--violet-300); --color-danger: #f2777a;}/* Уровень 3: компонент. Нужен не всегда, только для точек настройки */.btn { --btn-bg: var(--color-accent); background: var(--btn-bg);}.btn--danger { /* Меняем одну переменную вместо переписывания правил */ --btn-bg: var(--color-danger);}#Компонентные токены — точки настройки
Третий уровень нужен не везде. Он окупается там, где у компонента есть варианты: вместо переписывания правил меняется одна переменная.
Так не надо
.btn--danger {
background: var(--color-danger);
border-color: var(--color-danger);
}
.btn--danger:hover {
background: var(--color-danger-dark);
}Каждый вариант переписывает все правила заново. Появится третий — придётся копировать снова.
Так надо
.btn {
--btn-bg: var(--color-accent);
background: var(--btn-bg);
border-color: var(--btn-bg);
}
.btn--danger { --btn-bg: var(--color-danger); }
.btn--ok { --btn-bg: var(--color-ok); }Вариант — это одна строка. Логика оформления описана один раз, в блоке.
#Переключение без мигания
Классическая беда: страница на долю секунды показывается светлой, потом становится тёмной. Причина в том, что тему применяет скрипт, который выполняется после отрисовки.
Лечится встроенным скриптом в head — до того, как браузер что-либо нарисует. Он маленький и намеренно не вынесен в файл: внешний файл пришлось бы ждать.
<script> // Выполняется до первой отрисовки — поэтому мигания нет (function () { try { var saved = localStorage.getItem('theme'); var system = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; document.documentElement.dataset.theme = saved || system; } catch (err) { document.documentElement.dataset.theme = 'light'; } })();</script>#color-scheme и light-dark()
Свойство color-scheme сообщает браузеру, какие темы поддерживает страница. От него зависят системные элементы: полосы прокрутки, поля ввода, выпадающие списки. Без него в тёмной теме останутся белые скроллбары.
Функция light-dark() — недавнее сокращение: два значения в одной строке вместо двух блоков переопределения.
:root { /* Обязательно: иначе системные элементы останутся светлыми */ color-scheme: light dark; /* Первое значение для светлой темы, второе для тёмной */ --color-surface: light-dark(#fff, #16181d); --color-text: light-dark(#16181d, #e8eaf2);}#Контраст в тёмной теме
Тёмная тема — это не инверсия светлой. Если просто поменять чёрное на белое, получится неприятный результат: чистый белый текст на чистом чёрном фоне даёт слишком высокий контраст, от него быстро устают глаза.
Работающий приём — смягчить оба конца: фон не абсолютно чёрный, текст не абсолютно белый.
| Наивно | Как делают | |
|---|---|---|
| Фон | #000000 | #0b0e17 — почти чёрный с оттенком |
| Основной текст | #ffffff | #e8eaf2 — сбавленная яркость |
| Второстепенный текст | #888888 | #a7adc2 — светлее, чем кажется нужным |
| Акцент | Тот же, что в светлой | Светлее: насыщенные цвета на тёмном темнеют визуально |
#Не только цвета
Токенами описывают всё, что должно быть согласованным: отступы, радиусы, тени, скорости анимации, размеры шрифта.
Польза та же, что и с цветом: значения перестают быть случайными. Отступ 13px в одном месте и 14px в другом — это не решение дизайнера, это опечатка, которую шкала не пропустит.
:root { /* Шаг шкалы, а не произвольные числа */ --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-6: 24px; --radius-sm: 8px; --radius-md: 14px; --radius-full: 999px; --speed-fast: .15s; --speed-base: .3s; --ease: cubic-bezier(.22, 1, .36, 1);}#Как это выглядит в реальном проекте
@layer tokens { :root { color-scheme: light dark; /* Палитра: не меняется никогда */ --violet-600: #6544e0; --violet-300: #b9a5ff; --gray-950: #0b0e17; --gray-050: #f6f7fb; } /* Светлая — она же значение по умолчанию */ :root, [data-theme="light"] { --color-bg: var(--gray-050); --color-text: #131627; --color-accent: var(--violet-600); --color-border: rgb(16 20 40 / .1); } [data-theme="dark"] { --color-bg: var(--gray-950); --color-text: #e8eaf2; --color-accent: var(--violet-300); --color-border: rgb(255 255 255 / .08); } /* Третья тема — те же имена, другие значения. Ни один компонент об этом не знает */ [data-theme="contrast"] { --color-bg: #000; --color-text: #fff; --color-accent: #ffe600; --color-border: #fff; }}#Когда НЕ надо
- Три уровня на лендинге. Одна тема, десять цветов — хватит одного уровня семантических переменных. Палитра отдельно тут только добавит файлов.
- Токен на каждое значение. Если переменная используется один раз в одном месте, она не токен, а лишний уровень косвенности.
- Переменные внутри медиазапросов ради адаптива. Работает, но отлаживать тяжело: значение меняется невидимо. Для размеров лучше
clamp(). - Хранить в токенах логику. Переменная — это значение. Если хочется положить туда целое правило, нужен класс, а не токен.
#Поддержка браузерами
| Возможность | Состояние |
|---|---|
Пользовательские свойства --x | Везде, много лет |
color-scheme | Во всех современных браузерах |
prefers-color-scheme | Во всех современных браузерах |
light-dark() | Появилась недавно; где не поддержана, значение игнорируется — нужен запасной |
prefers-contrast | Поддерживается, но по-разному; лучше давать явный переключатель |
Где спотыкаются почти все
Компоненты ссылаются на палитру напрямую
Почему В тёмной теме приходится переопределять --violet-600 на не-фиолетовый. Имя начинает врать, и появляются переменные-двойники.
Как надо Компонент знает только семантические имена. Палитру трогает лишь второй уровень.
Тема мигает при загрузке
Почему Скрипт применяет тему после отрисовки, и человек успевает увидеть светлую вспышку.
Как надо Встроенный скрипт в head, до стилей и до разметки.
Забыт color-scheme
Почему Полосы прокрутки, поля ввода и выпадающие списки остаются светлыми в тёмной теме.
Как надо Пиши color-scheme: light dark в :root.
Тема переключается классом на body
Почему Стили из head применяются раньше, чем разметка body, — снова мигание. Плюс до body не дотянуться из :root.
Как надо Ставь атрибут на documentElement, то есть на <html>.
- Дизайн-токен design token
- Именованное значение оформления: цвет, отступ, радиус, скорость.
- Сырое значение primitive token
- Палитра. Название описывает само значение:
--violet-600. - Семантический токен semantic token
- Название описывает роль:
--color-danger. Именно они переключаются темами. - Компонентный токен component token
- Точка настройки конкретного блока:
--btn-bg. - Цветовая схема color-scheme
- Свойство, сообщающее браузеру о поддерживаемых темах. Влияет на системные элементы.
Проверь себя
Роль сохраняется при любой теме, конкретный цвет — нет.
Тема должна быть установлена раньше, чем браузер что-либо нарисует. Внешний файл для этого слишком медленный.
Своими стилями системные элементы не покрасить. Об этом говорит именно <code>color-scheme</code>.
Логика оформления описывается один раз в блоке, а модификатор меняет только значение.
Корневой элемент существует уже во время выполнения скрипта в <code>head</code>, а <code>body</code> ещё нет.
Задание
Третья тема без правки компонентов
В стилях есть светлая и тёмная темы, но компоненты ссылаются прямо на палитру — поэтому тёмная работает наполовину.
Наведи порядок: заведи семантические токены (--color-surface, --color-text, --color-accent), переведи компоненты на них, а потом добавь третью тему [data-theme="contrast"], не трогая ни одного правила компонента. Не забудь color-scheme.
Подсказка 1
Сначала заведи семантический слой: :root { --color-surface: var(--white); --color-text: var(--gray-900); --color-accent: var(--violet-600); }
Подсказка 2
Потом замени в компонентах: background: var(--color-surface); color: var(--color-text); и так далее.
Подсказка 3
Третья тема — те же имена, другие значения: [data-theme="contrast"] { --color-surface: #000; --color-text: #fff; --color-accent: #ffe600; }
Показать решение
<div data-theme="contrast" class="page"> <article class="card"> <h3 class="card__title">Раф на кокосовом</h3> <p class="card__text">Мягкий вкус</p> <button class="btn">В корзину</button> </article></div>:root { color-scheme: light dark; /* Палитра */ --violet-600: #6544e0; --gray-900: #16181d; --gray-100: #f4f4f8; --white: #fff; /* Смысл: только эти имена знают компоненты */ --color-bg: var(--gray-100); --color-surface: var(--white); --color-text: var(--gray-900); --color-accent: var(--violet-600); --color-on-accent: var(--white);}[data-theme="contrast"] { --color-bg: #000; --color-surface: #000; --color-text: #fff; --color-accent: #ffe600; --color-on-accent: #000;}.page { padding: 20px; background: var(--color-bg); }.card { padding: 16px; background: var(--color-surface); color: var(--color-text); border-radius: 14px;}.card__title { margin: 0 0 6px; font-size: 17px; }.card__text { margin: 0 0 12px; font-size: 14px; }.btn { padding: 9px 16px; font: inherit; color: var(--color-on-accent); background: var(--color-accent); border: 0; border-radius: 999px;}