Уроки

Динамическая цветовая тематизация в React: за пределами CSS-переменных

9 мин чтения

Большинство руководств по тематизации останавливаются на переключателе тёмного режима. Добавить класс к <html>, переключить несколько CSS-переменных — и готово. Это покрывает распространённый случай, но упускает более интересную задачу: что если пользователи могут выбрать собственный фирменный цвет? Что если ваш SaaS-продукт обслуживает нескольких клиентов, каждый со своей идентичностью? Что если тема должна генерировать всю гармоничную палитру из одного входного hex-значения во время выполнения?

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

Это руководство подробно рассматривает каждый из этих уровней.


Подход с CSS-переменными для тем React

Основа: архитектура семантических токенов

Любая система динамической тематизации начинается с одной основы: кастомные CSS-свойства, названные по семантической роли, а не по значению цвета. Не называйте переменную --blue-500. Называйте её --brand-primary. Значение может меняться; роль — нет:

/* globals.css */
:root {
  /* Семантические токены — именно их используют компоненты */
  --bg-base: #F8FAFC;
  --bg-surface: #FFFFFF;
  --bg-sunken: #F1F5F9;

  --text-primary: #1E293B;
  --text-secondary: #64748B;
  --text-disabled: #94A3B8;

  --brand-primary: #2563EB;
  --brand-hover: #1D4ED8;
  --brand-subtle: #EFF6FF;
  --on-brand: #FFFFFF;

  --border-default: #E2E8F0;
  --border-strong: #CBD5E1;

  --status-error: #DC2626;
  --status-success: #16A34A;
  --status-warning: #D97706;
}

Компоненты ссылаются на токены, никогда на исходные цвета:

.card {
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  color: var(--text-primary);
}

.btn-primary {
  background: var(--brand-primary);
  color: var(--on-brand);
}

.btn-primary:hover {
  background: var(--brand-hover);
}

При обновлении --brand-primary из JavaScript каждый компонент, ссылающийся на него, мгновенно обновляется — без повторного рендеринга React, без prop drilling, без подписок на контекст. CSS управляет каскадом самостоятельно.

Запись токенов из JavaScript

DOM API для установки кастомных свойств прост:

document.documentElement.style.setProperty('--brand-primary', '#7C3AED');
document.documentElement.style.setProperty('--brand-hover', '#6D28D9');
document.documentElement.style.setProperty('--brand-subtle', '#EDE9FE');
document.documentElement.style.setProperty('--on-brand', '#FFFFFF');

Чтение обратно:

const brand = getComputedStyle(document.documentElement)
  .getPropertyValue('--brand-primary')
  .trim();

Это механическая основа. Сложная часть — генерация полного набора токенов из одного фирменного hex-значения.


Генерация тем во время выполнения с помощью цветовых алгоритмов

Проблема с ручными наборами токенов

Если пользователи могут выбрать любой цвет — включая нестандартные, такие как #FF5733 или #0D9488, — нельзя жёстко задать тёмный вариант, состояние hover или цвет текста поверх бренда. Их нужно вычислять из входного значения.

Генерация полного набора токенов

Вот полная функция, принимающая фирменный hex и производящая все нужные теме токены:

import chroma from 'chroma-js';

function generateThemeTokens(brandHex) {
  const brand = chroma(brandHex);

  // Определить, должен ли текст поверх бренда быть чёрным или белым
  // На основе относительной яркости WCAG
  const brandLuminance = brand.luminance();
  const onBrand = brandLuminance > 0.179 ? '#000000' : '#FFFFFF';

  // Генерация шкалы от светлого оттенка до тёмного в OKLCH
  // для перцептивно равномерных шагов
  const scale = chroma.scale([
    chroma(brandHex).brighten(2.5).desaturate(0.5).hex(),
    brandHex,
    chroma(brandHex).darken(2.5).hex(),
  ]).mode('oklch').colors(11);

  return {
    '--brand-primary': brandHex,
    '--brand-hover': chroma(brandHex).darken(0.5).hex(),
    '--brand-active': chroma(brandHex).darken(1).hex(),
    '--brand-subtle': scale[1],   // Очень светлый оттенок
    '--brand-muted': scale[2],    // Светлый оттенок
    '--on-brand': onBrand,

    // Примитивы шкалы для полного доступа к палитре
    '--brand-50': scale[0],
    '--brand-100': scale[1],
    '--brand-200': scale[2],
    '--brand-300': scale[3],
    '--brand-400': scale[4],
    '--brand-500': scale[5],
    '--brand-600': scale[6],
    '--brand-700': scale[7],
    '--brand-800': scale[8],
    '--brand-900': scale[9],
    '--brand-950': scale[10],
  };
}

function applyTheme(brandHex) {
  const tokens = generateThemeTokens(brandHex);
  const root = document.documentElement;
  Object.entries(tokens).forEach(([prop, value]) => {
    root.style.setProperty(prop, value);
  });
}

Доступные цветовые пары

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

function ensureContrast(foreground, background, minRatio = 4.5) {
  let color = chroma(foreground);
  let ratio = chroma.contrast(color.hex(), background);

  // Если контраст недостаточен, затемняем передний план до прохождения проверки
  let iterations = 0;
  while (ratio < minRatio && iterations < 20) {
    color = color.darken(0.2);
    ratio = chroma.contrast(color.hex(), background);
    iterations++;
  }

  return color.hex();
}

// Для цветной текстовой метки поверх бренда на белом фоне
const safeLabel = ensureContrast('#FFD700', '#FFFFFF'); // Жёлтый скорректирован до прохождения AA

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

Дополнительные и акцентные цвета

Генератор палитр показывает, как из одного базового цвета получается полное семейство гармоний. Ту же логику можно реализовать в коде с помощью поворота оттенка:

function generateComplementaryAccent(brandHex) {
  const hsl = chroma(brandHex).hsl();
  // Поворот оттенка на 180° для дополнительного, сдвиг светлоты для баланса
  const complementHue = (hsl[0] + 180) % 360;
  return chroma.hsl(complementHue, hsl[1] * 0.9, hsl[2]).hex();
}

function generateAnalogousAccent(brandHex, degrees = 30) {
  const hsl = chroma(brandHex).hsl();
  const analogousHue = (hsl[0] + degrees) % 360;
  return chroma.hsl(analogousHue, hsl[1], hsl[2]).hex();
}

React Context для управления состоянием темы

Архитектура ThemeContext

Контекст темы служит мостом между пользовательскими взаимодействиями (выбор цвета, переключение тёмного режима) и применением токенов на уровне DOM. Он должен быть тонким: хранит состояние, применяет его к DOM и предоставляет сеттер. Вычисление цветов происходит за пределами контекста:

// contexts/ThemeContext.tsx
import {
  createContext,
  useContext,
  useEffect,
  useState,
  useCallback,
  type ReactNode,
} from 'react';
import { generateThemeTokens } from '../lib/theme-generator';

interface ThemeState {
  brandHex: string;
  mode: 'light' | 'dark' | 'system';
}

interface ThemeContextValue extends ThemeState {
  setBrand: (hex: string) => void;
  setMode: (mode: 'light' | 'dark' | 'system') => void;
  resolvedMode: 'light' | 'dark';
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

const STORAGE_KEY = 'app-theme';

function getStoredTheme(): ThemeState {
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    if (stored) return JSON.parse(stored) as ThemeState;
  } catch {}
  return { brandHex: '#2563EB', mode: 'system' };
}

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [state, setState] = useState<ThemeState>(getStoredTheme);

  const systemDark =
    typeof window !== 'undefined'
      ? window.matchMedia('(prefers-color-scheme: dark)').matches
      : false;

  const resolvedMode: 'light' | 'dark' =
    state.mode === 'system'
      ? systemDark ? 'dark' : 'light'
      : state.mode;

  // Применять CSS-токены при каждом изменении состояния
  useEffect(() => {
    const tokens = generateThemeTokens(state.brandHex);
    const root = document.documentElement;
    Object.entries(tokens).forEach(([prop, value]) => {
      root.style.setProperty(prop, value as string);
    });
    root.setAttribute('data-theme', resolvedMode);
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  }, [state, resolvedMode]);

  // Реагировать на изменения системных предпочтений
  useEffect(() => {
    if (state.mode !== 'system') return;
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e: MediaQueryListEvent) => {
      document.documentElement.setAttribute(
        'data-theme',
        e.matches ? 'dark' : 'light',
      );
    };
    media.addEventListener('change', handler);
    return () => media.removeEventListener('change', handler);
  }, [state.mode]);

  const setBrand = useCallback((hex: string) => {
    setState(prev => ({ ...prev, brandHex: hex }));
  }, []);

  const setMode = useCallback((mode: 'light' | 'dark' | 'system') => {
    setState(prev => ({ ...prev, mode }));
  }, []);

  return (
    <ThemeContext.Provider value={{ ...state, setBrand, setMode, resolvedMode }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextValue {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
  return ctx;
}

Компонент выбора цвета

После настройки контекста выбор цвета становится тонким UI-слоем:

// components/BrandColorPicker.tsx
import { useTheme } from '../contexts/ThemeContext';
import { useState, useRef } from 'react';

export function BrandColorPicker() {
  const { brandHex, setBrand } = useTheme();
  const [localHex, setLocalHex] = useState(brandHex);
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setLocalHex(value);

    // Debounce дорогостоящей генерации токенов
    clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => {
      if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
        setBrand(value);
      }
    }, 150);
  };

  return (
    <div className="flex items-center gap-3">
      <label htmlFor="brand-picker" className="text-sm font-medium">
        Цвет бренда
      </label>
      <div className="relative">
        <input
          id="brand-picker"
          type="color"
          value={localHex}
          onChange={handleChange}
          className="sr-only"
        />
        <label
          htmlFor="brand-picker"
          className="block w-8 h-8 rounded-md cursor-pointer ring-2 ring-offset-2"
          style={{
            backgroundColor: localHex,
            // цвет кольца использует токен бренда
            '--tw-ring-color': 'var(--brand-primary)',
          } as React.CSSProperties}
          aria-label={`Текущий цвет бренда: ${localHex}`}
        />
      </div>
      <input
        type="text"
        value={localHex}
        onChange={handleChange}
        className="w-24 px-2 py-1 text-sm font-mono border rounded"
        placeholder="#2563EB"
        aria-label="Hex-значение цвета бренда"
      />
    </div>
  );
}

Debounce на 150 мс критически важен — без него generateThemeTokens вызывается при каждом нажатии клавиши при наборе в hex-поле, что излишне дорогостояще.


Динамические цветовые классы Tailwind CSS

Сложность с Tailwind и цветами во время выполнения

Tailwind генерирует утилитарные классы во время сборки. Классы вроде bg-brand-500 существуют в таблице стилей только если сканер Tailwind обнаружил их в исходных файлах. Цвет, определяемый во время выполнения (то, что только что выбрал пользователь), не будет в таблице стилей.

Есть два решения, каждое для разных нужд:

Решение 1: мост через CSS-переменную

Чистейший подход — определить небольшой набор токенов темы Tailwind, указывающих на CSS-кастомные свойства, затем управлять значениями этих свойств во время выполнения:

/* styles.css (Tailwind v4) */
@import "tailwindcss";

@theme {
  --color-brand: var(--brand-primary);
  --color-brand-hover: var(--brand-hover);
  --color-brand-subtle: var(--brand-subtle);
  --color-on-brand: var(--on-brand);
}

Теперь bg-brand, text-brand и hover:bg-brand-hover — валидные классы Tailwind, отражающие любое значение --brand-primary во время выполнения:

<button className="bg-brand text-on-brand hover:bg-brand-hover px-4 py-2 rounded-lg">
  Основное действие
</button>

Изменение --brand-primary через JavaScript мгновенно обновляет каждый элемент с bg-brand — повторный рендеринг не нужен.

Решение 2: инлайн-стили для единичных динамических цветов

Когда компонент нуждается в по-настоящему уникальном цвете во время выполнения (например, цветовой образец в просмотрщике палитры), используйте инлайн-стиль. Синтаксис произвольных значений Tailwind bg-[#FF5733] работает для статических значений, известных во время сборки, но не для bg-[${dynamicHex}] внутри шаблонного литерала в JSX — этого класса не будет в таблице стилей.

// Правильно для по-настоящему динамических цветов:
<div
  style={{ backgroundColor: swatch.hex }}
  className="w-8 h-8 rounded"
  aria-label={swatch.name}
/>

// Это НЕ будет работать во время выполнения (класса нет в таблице стилей):
<div className={`bg-[${swatch.hex}]`} /> // Неверно

Сохранение пользовательских цветовых предпочтений

localStorage для постоянства между сессиями

ThemeContext выше уже записывает в localStorage в useEffect. Ключевой паттерн для совместимости с SSR — ленивая инициализация состояния: передача функции в useState, которая читает localStorage только на клиенте, никогда при серверном рендеринге:

const [state, setState] = useState<ThemeState>(() => {
  // Эта функция выполняется только на клиенте, после гидратации
  if (typeof window === 'undefined') {
    return { brandHex: '#2563EB', mode: 'system' };
  }
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    return stored ? JSON.parse(stored) : { brandHex: '#2563EB', mode: 'system' };
  } catch {
    return { brandHex: '#2563EB', mode: 'system' };
  }
});

Предотвращение вспышки темы по умолчанию при загрузке

Для серверно рендеримых приложений (Next.js App Router) дерево компонентов рендерится на сервере до попадания в браузер. Если инициализировать с темой по умолчанию, пользователи, сохранившие кастомную тему, увидят вспышку — синий по умолчанию появляется на мгновение до загрузки сохранённого фиолетового.

Решение — блокирующий инлайн-скрипт в <head>, применяющий сохранённую тему до рендеринга браузером каких-либо пикселей:

// app/layout.tsx (Next.js App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                try {
                  var stored = JSON.parse(localStorage.getItem('app-theme') || '{}');
                  var brand = stored.brandHex || '#2563EB';
                  var mode = stored.mode || 'system';
                  var resolvedMode = mode === 'system'
                    ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
                    : mode;
                  document.documentElement.setAttribute('data-theme', resolvedMode);
                  // Минимальные инлайн-токены для предотвращения вспышки
                  document.documentElement.style.setProperty('--brand-primary', brand);
                } catch(e) {}
              })();
            `,
          }}
        />
      </head>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

Инлайн-скрипт синхронно применяет только наиболее критичные токены (цвет бренда, атрибут режима темы). Полный набор токенов применяется ThemeProvider после гидратации — но поскольку доминирующие визуальные токены уже верны, пользователи не видят вспышки.

Серверная персистентность через Cookies

Для аутентифицированных приложений хранение темы в cookie (читаемом сервером) позволяет SSR с правильной темой с первого байта:

// middleware.ts (Next.js)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const brandHex = request.cookies.get('brand-hex')?.value ?? '#2563EB';
  const mode = request.cookies.get('theme-mode')?.value ?? 'system';

  // Передать тему странице через заголовки ответа или параметры поиска
  const response = NextResponse.next();
  response.headers.set('x-brand-hex', brandHex);
  response.headers.set('x-theme-mode', mode);
  return response;
}

Читайте заголовки в корневом серверном компоненте layout.tsx и передавайте начальную тему в ThemeProvider как пропс. Провайдер инициализируется с известной серверу темой, полностью устраняя вспышку.


Генерация доступных вариантов тёмного режима

При генерации светлой темы из цвета бренда также нужна версия для тёмного режима. Сложность: тот же hex бренда, хорошо работающий на светлом фоне — например, #2563EB, — нередко не проходит требования WCAG по контрасту на тёмных поверхностях.

Надёжный подход генерирует два набора токенов — один для светлого, один для тёмного режима — из одного входного значения бренда:

function generateDarkTokens(brandHex) {
  const brand = chroma(brandHex);
  const hsl = brand.hsl();

  // В тёмном режиме осветляем бренд для обеспечения контраста на тёмных поверхностях
  const darkBrand = chroma.hsl(hsl[0], hsl[1] * 0.9, Math.max(0.55, hsl[2] + 0.15));

  return {
    '[data-theme="dark"]': {
      '--brand-primary': darkBrand.hex(),
      '--brand-hover': darkBrand.lighten(0.3).hex(),
      '--brand-subtle': darkBrand.darken(2).desaturate(0.5).hex(),
      '--on-brand': '#000000', // Часто чёрный поверх осветлённого бренда в тёмном режиме
      '--bg-base': '#0F172A',
      '--bg-surface': '#1E293B',
      '--text-primary': '#F1F5F9',
      '--text-secondary': '#94A3B8',
    },
  };
}

Применяйте тёмные токены через паттерн атрибута data-theme="dark". Когда ThemeProvider устанавливает document.documentElement.setAttribute('data-theme', 'dark'), CSS-каскад применяет значения токенов тёмного режима.

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


Основные выводы

  • CSS-кастомные свойства — основа: семантические токены, названные по роли, а не по значению цвета, позволяют полностью менять тему без изменения компонентов.
  • Генерация токенов во время выполнения из одного фирменного hex требует: шкалы светлости/темноты, проверки контраста WCAG для текста поверх бренда и отдельного вычисления для вариантов тёмного режима.
  • React context управляет состоянием темы (активный hex бренда, режим светлый/тёмный) и применяет токены к DOM как побочный эффект — компонентам не нужно подписываться на контекст темы для применения цветов.
  • Применяйте debounce к дорогостоящим вычислениям цветов (150 мс) при подключении к непрерывным вводам, таким как выбор цвета или ползунки.
  • Для Tailwind CSS: определите мост через CSS-переменную в @theme, чтобы утилиты bg-brand отражали значение токена во время выполнения; используйте инлайн-стили только для по-настоящему уникальных динамических цветов отдельных элементов.
  • Предотвращайте вспышку темы по умолчанию с помощью синхронного блокирующего скрипта в <head>, применяющего наиболее критичные токены до рендеринга.
  • Для SSR-приложений с темами на пользователя храните тему в cookie, чтобы сервер мог рендерить правильный начальный HTML.
  • Используйте Генератор оттенков для проверки и валидации сгенерированных цветовых шкал в полном диапазоне 50–950, и Генератор палитр для проверки гармонии и коэффициентов контраста во всей теме.

Похожие цвета

Похожие бренды

Похожие инструменты