Динамическая цветовая тематизация в React: за пределами CSS-переменных
Embed This Widget
Add the script tag and a data attribute to embed this widget.
Embed via iframe for maximum compatibility.
<iframe src="https://colorfyi.com/iframe/entity//" width="420" height="400" frameborder="0" style="border:0;border-radius:10px;max-width:100%" loading="lazy"></iframe>
Paste this URL in WordPress, Medium, or any oEmbed-compatible platform.
https://colorfyi.com/entity//
Add a dynamic SVG badge to your README or docs.
[](https://colorfyi.com/entity//)
Use the native HTML custom element.
Большинство руководств по тематизации останавливаются на переключателе тёмного режима. Добавить класс к <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, и Генератор палитр для проверки гармонии и коэффициентов контраста во всей теме.
Похожие цвета
Похожие бренды
Похожие инструменты
Генератор палитр
Генерируйте гармоничные цветовые палитры, используя комплементарные, аналоговые, триадные и расщеплённо-комплементарные схемы.
Генератор оттенков
Генерируйте шкалы оттенков в стиле Tailwind CSS (50–950) из любого базового цвета для дизайн-систем.