almdev Технический блог

Дизайн-токены в шаблоне: одна палитра на десяток проектов
Три слоя переменных, тёмная тема без дублирования и скрипт, который убирает мигание.

Как устроены дизайн-токены, которые переезжают между проектами: примитивы, семантические роли, компонентные значения, переопределение для тёмной темы и раннее определение темы до загрузки скриптов.

Подкатегория: Дизайн-система

Чтение
5 мин
Технологии / версии
Tailwind · CSS · Тёмная тема · Дизайн-система
21 июл 2026 · 5 мин · 6 просмотров · Frontend
Единая палитра распределяет четыре цветовые темы между интерфейсами
Дизайн-система 07/2026

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

Сводить всё к общей библиотеке компонентов я не стал — проекты слишком разные. Свёл к общему набору токенов, и это оказалось достаточным.

Токены в CSS, а не в конфиге сборщика

Первая версия жила в конфигурации Tailwind: палитра в объекте, классы генерируются. Работало, пока цвет нужен был только в утилитах.

Сломалось на первом же месте, где потребовалось значение в собственном правиле — тень с цветом акцента, градиент, значение для canvas. Из конфига сборщика цвет не достать, приходится дублировать значение руками, и дубли расходятся.

Сейчас источник истины — переменные CSS. Ниже фрагмент: остальные оттенки примитивов в полном файле объявлены по той же схеме.

:root {
    /* 1. примитивы: сырые значения, нигде не используются напрямую */
    --slate-50:  #f7f8fa;
    --slate-100: #edeff2;
    --slate-700: #3c4550;
    --slate-900: #141920;
    --teal-600:  #0f6b62;
    --teal-300:  #54c3b3;

    /* 2. роли: чем является цвет в интерфейсе */
    --color-page:     var(--slate-100);
    --color-surface:  #ffffff;
    --color-ink:      var(--slate-900);
    --color-ink-soft: var(--slate-700);
    --color-accent:   var(--teal-600);
    --color-line:     #c7ced7;

    /* 3. компонентные значения: только там, где роль недостаточно конкретна */
    --card-radius: 2px;
    --card-border: 1px solid var(--color-line);
    --header-height: 3.5rem;
}

Три слоя — не теория, а практическая необходимость.

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

Роли — это то, чем пользуются все правила. Ни одно правило в проекте не должно ссылаться на --slate-900 напрямую. Смысл в том, что при смене темы меняется роль, а не примитив.

Третий слой я долго считал избыточным и добавил после того, как в четырёх местах написал border-radius: 2px и в пятом ошибся.

Тёмная тема через переопределение ролей

Ключевое правило: в тёмной теме переопределяются только роли. Ни одного нового правила, ни одного дублированного селектора компонента.

@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        --color-page:     #0e1116;
        --color-surface:  #161a21;
        --color-ink:      #e6eaf0;
        --color-ink-soft: #b3bcc7;
        --color-accent:   var(--teal-300);
        --color-line:     #2d343e;
    }
}

:root[data-theme="dark"] {
    --color-page:     #0e1116;
    --color-surface:  #161a21;
    --color-ink:      #e6eaf0;
    --color-ink-soft: #b3bcc7;
    --color-accent:   var(--teal-300);
    --color-line:     #2d343e;
}

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

Отсюда конструкция :not([data-theme="light"]) в медиавыражении: явный выбор светлой темы должен побеждать тёмную систему. И отдельный блок с атрибутом — чтобы явный выбор тёмной побеждал светлую систему.

Акцент в тёмной теме — не тот же цвет. Тёмно-бирюзовый на почти чёрном фоне нечитаем. Роль одна, примитив другой, и в этом весь смысл разделения слоёв.

Мигание при загрузке

Тема хранится в локальном хранилище. Если применять её из основного скрипта, страница успевает отрисоваться в светлой и потом дёрнуться в тёмную. Выглядит как дефект, и это он и есть.

Лечится крошечным скриптом до всех остальных, синхронным, прямо в head:

<script src="/theme-init.js"></script>
(function () {
    try {
        var stored = localStorage.getItem('theme');
        if (stored === 'dark' || stored === 'light') {
            document.documentElement.dataset.theme = stored;
        }
    } catch (e) {
        // storage недоступен или запрещён — остаётся системная тема
    }
})();

Два момента, из-за которых это работает.

Скрипт отдельным файлом ради браузерного кеша. Политика CSP разрешает этот same-origin файл через 'self'; встроенный вариант тоже возможен, но ему понадобился бы nonce или hash. Одноразовый nonce относится к HTML-ответу и не мешает кешировать внешний файл.

Блок try оставляю как защиту. Приватный режим сам по себе обычно не запрещает localStorage, но обращение может бросить исключение при отключённом storage, ограниченном origin или sandbox-контексте.

Тематические классы разделов

В блоге у каждого раздела свой акцент: бэкенд, фронтенд, AI, инфраструктура. Реализовано это классом на обёртке, который переопределяет одну роль:

Ниже сокращённый фрагмент: значения всех упомянутых примитивов объявлены в полном файле палитры.

.t-backend  { --color-accent: var(--teal-600); }
.t-frontend { --color-accent: var(--indigo-600); }
.t-ai       { --color-accent: var(--violet-600); }
.t-ops      { --color-accent: var(--amber-700); }

@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .t-backend  { --color-accent: var(--teal-300); }
    :root:not([data-theme="light"]) .t-frontend { --color-accent: var(--indigo-300); }
    :root:not([data-theme="light"]) .t-ai       { --color-accent: var(--violet-300); }
    :root:not([data-theme="light"]) .t-ops      { --color-accent: var(--amber-300); }
}

:root[data-theme="dark"] .t-backend  { --color-accent: var(--teal-300); }
:root[data-theme="dark"] .t-frontend { --color-accent: var(--indigo-300); }
:root[data-theme="dark"] .t-ai       { --color-accent: var(--violet-300); }
:root[data-theme="dark"] .t-ops      { --color-accent: var(--amber-300); }

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

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

Перенос в соседний проект

Файл с токенами копируется целиком. Меняется в нём ровно один блок — примитивы акцента. Роли, отступы, размеры шрифта и радиусы остаются.

Что не переносится: компонентный слой. У блога есть --header-height, у панели задач — --sidebar-width, и общего в них нет. Этот слой каждый проект пишет свой, и это нормально.

Реальная проверка переносимости — новый проект собирается с узнаваемым видом за час, а не за день. У меня это работает начиная с четвёртого проекта. Первые три ушли на то, чтобы понять, какие роли вообще нужны.

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

Контраст как часть работы, а не как этап

Тёмная тема проваливает контраст чаще светлой, и всегда в одном месте — приглушённый текст. --color-ink-soft на --color-surface легко оказывается на уровне 3.2 при норме 4.5.

Я проверяю четыре пары при каждом изменении палитры: основной текст на странице, основной на поверхности, приглушённый на поверхности, акцент на поверхности. По четыре пары в каждой теме — восемь сочетаний за один проход.

Отдельно — акцент как цвет текста. Бирюзовый, отлично работающий как фон кнопки с белым текстом, в роли цвета ссылки на белом фоне даёт 3.1. Для этого случая у меня отдельный токен --color-accent-text, темнее основного. Это тот редкий случай, когда пришлось завести тринадцатую роль.

Что дало больше всего

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

Дало скорость. Новый экран верстается без вопросов «какой тут серый» и «сколько отступ», потому что ответ уже записан. И тёмная тема появляется бесплатно, вместо того чтобы быть отдельной задачей на два дня, которую вечно откладывают.

#tailwind #css #temnaya-tema #dizayn-sistema