Уроки

Тематизация цвета в React: CSS-переменные и Context

9 мин чтения

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

Компонентная модель React и CSS-кастомные свойства — естественная пара для тематизации. CSS-переменные декларативно управляют цветовыми значениями; React Context управляет состоянием темы; Tailwind CSS (в современных проектах) объединяет оба. Это руководство охватывает каждый слой: как структурировать архитектуру темы, реализовать основу на CSS-переменных, подключить состояние React и обработать мультибрендовые сценарии.


Паттерны архитектуры тем

Паттерн 1: только CSS-переменные (без JavaScript-состояния)

Простейший паттерн для базовой поддержки тёмного режима не требует никакого состояния React. Медиазапрос prefers-color-scheme изменяет значения кастомных свойств, и каждый компонент обновляется автоматически:

/* globals.css */
:root {
  --color-bg: #F8FAFC;
  --color-text: #1E293B;
  --color-brand: #2563EB;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0F172A;
    --color-text: #F1F5F9;
    --color-brand: #60A5FA;
  }
}

Компоненты используют var(--color-bg) и никогда не нуждаются в знании текущей темы. Браузер берёт всё на себя.

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

Ограничение: нет возможности для пользователей переопределить системную настройку внутри приложения.

Паттерн 2: переключение темы через data-атрибут

Добавьте атрибут data-theme на элемент <html>, чтобы сделать активную тему явной и переопределяемой. Это совместимо с SSR, позволяет избежать вспышки неправильной темы и тривиально сочетается с сохранением в localStorage:

/* По умолчанию: светлая */
[data-theme="light"],
:root {
  --color-bg: #F8FAFC;
  --color-text: #1E293B;
  --color-brand: #2563EB;
  --color-surface: #FFFFFF;
  --color-border: #E2E8F0;
}

/* Тёмная */
[data-theme="dark"] {
  --color-bg: #0F172A;
  --color-text: #F1F5F9;
  --color-brand: #60A5FA;
  --color-surface: #1E293B;
  --color-border: #334155;
}
// Применяем тему до первой отрисовки (инлайн-скрипт в <head>)
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);

Когда использовать: большинство продакшен-приложений, нуждающихся в управляемом пользователем переключателе. Подход с data-атрибутом — текущий индустриальный стандарт, используемый реализацией тёмного режима Tailwind CSS, Radix UI и большинством современных дизайн-систем.

Паттерн 3: переключение темы через классы

Аналогично data-атрибуту, но использует CSS-классы. На этом паттерне основана конфигурация darkMode: 'class' Tailwind:

.dark {
  --color-bg: #0F172A;
  --color-text: #F1F5F9;
}

Добавление класса dark к <html> активирует значения тёмных токенов. Tailwind затем применяет утилиты с префиксом dark:, когда этот класс присутствует.

Когда использовать: проекты, использующие Tailwind CSS со стратегией тёмного режима class.


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

Семантическое именование вместо описательного

Самое важное решение в системе токенов — именование. Именование по цветовому значению (--blue-500, --gray-900) делает переменную понятной в изоляции, но неприменимой для тематизации — изменение --blue-500 на пурпурный разрушает семантику имени.

Именование по семантической роли (--color-brand, --color-text-muted) позволяет значениям полностью меняться между темами, сохраняя корректность компонентов:

:root {
  /* ---- Примитивы цвета (не используются напрямую в компонентах) ---- */
  --blue-500: #3B82F6;
  --blue-700: #1D4ED8;
  --slate-50: #F8FAFC;
  --slate-800: #1E293B;
  --slate-900: #0F172A;

  /* ---- Семантические токены (используются в компонентах) ---- */
  /* Текст */
  --text-primary: var(--slate-800);
  --text-secondary: #64748B;
  --text-disabled: #94A3B8;
  --text-inverse: var(--slate-50);

  /* Поверхности */
  --bg-base: var(--slate-50);
  --bg-elevated: #FFFFFF;
  --bg-sunken: #F1F5F9;

  /* Бренд */
  --brand: var(--blue-700);
  --brand-hover: #1E40AF;
  --brand-subtle: #EFF6FF;
  --on-brand: #FFFFFF;

  /* Обратная связь */
  --error: #DC2626;
  --error-bg: #FEF2F2;
  --success: #16A34A;
  --success-bg: #F0FDF4;
  --warning: #D97706;
  --warning-bg: #FFFBEB;
}

[data-theme="dark"] {
  --text-primary: #F1F5F9;
  --text-secondary: #94A3B8;
  --text-disabled: #475569;
  --text-inverse: #0F172A;

  --bg-base: #0F172A;
  --bg-elevated: #1E293B;
  --bg-sunken: #0D1426;

  --brand: #60A5FA;
  --brand-hover: #93C5FD;
  --brand-subtle: #172554;
  --on-brand: #0F172A;

  --error: #F87171;
  --error-bg: #1C0A0A;
  --success: #4ADE80;
  --success-bg: #052E16;
  --warning: #FCD34D;
  --warning-bg: #1C1000;
}

Двухуровневая система (примитивы + семантические токены) даёт лучшее из обоих миров: сырую цветовую палитру для справки и семантические токены для использования в компонентах.

Генерация цветовой шкалы

Прежде чем строить систему токенов, необходима сырая цветовая палитра. Генератор оттенков создаёт полную шкалу 50–950 из единственного фирменного цвета — тот же шаблон шкалы, что используется в Tailwind CSS. Введите hex-код фирменного цвета и получите полный набор вариантов от тёмного к светлому, готовых к использованию в качестве примитивных токенов.

Например, введя #2563EB в качестве фирменного синего, генератор создаёт:

  • blue-50: #EFF6FF
  • blue-100: #DBEAFE
  • blue-500: #3B82F6
  • blue-700: #1D4ED8
  • blue-900: #1E3A8A
  • blue-950: #172554

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


React Context для состояния темы

Паттерн ThemeContext

Для приложений с управляемым пользователем переключателем темы React Context обеспечивает слой управления состоянием. Context хранит имя активной темы и предоставляет функцию переключения:

// contexts/ThemeContext.tsx
import { createContext, useContext, useEffect, useState } from 'react';

type Theme = 'light' | 'dark' | 'system';

interface ThemeContextValue {
  theme: Theme;
  resolvedTheme: 'light' | 'dark'; // Фактически применённая тема (разрешает 'system')
  setTheme: (theme: Theme) => void;
}

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

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setThemeState] = useState<Theme>(() => {
    if (typeof window === 'undefined') return 'system';
    return (localStorage.getItem('theme') as Theme) || 'system';
  });

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

  const resolvedTheme: 'light' | 'dark' =
    theme === 'system'
      ? (systemPrefersDark ? 'dark' : 'light')
      : theme;

  useEffect(() => {
    const root = document.documentElement;
    root.setAttribute('data-theme', resolvedTheme);
    localStorage.setItem('theme', theme);
  }, [theme, resolvedTheme]);

  // Слушаем изменения системных настроек при theme === 'system'
  useEffect(() => {
    if (theme !== 'system') return;
    const media = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = () => {
      document.documentElement.setAttribute(
        'data-theme',
        media.matches ? 'dark' : 'light'
      );
    };
    media.addEventListener('change', handler);
    return () => media.removeEventListener('change', handler);
  }, [theme]);

  const setTheme = (newTheme: Theme) => {
    setThemeState(newTheme);
  };

  return (
    <ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

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

ThemeProvider оборачивает ваше приложение (или соответствующее поддерево), а хук useTheme предоставляет доступ к состоянию темы из любого места в дереве компонентов:

// components/ThemeToggle.tsx
import { useTheme } from '../contexts/ThemeContext';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <button
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
      aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
      style={{
        background: 'var(--bg-elevated)',
        color: 'var(--text-primary)',
        border: '1px solid var(--color-border)',
        padding: '8px 16px',
        borderRadius: '8px',
        cursor: 'pointer',
      }}
    >
      {theme === 'dark' ? '☀ Light' : '☾ Dark'}
    </button>
  );
}

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

Предотвращение вспышки неправильной темы

Приложения с серверным рендерингом сталкиваются с проблемой вспышки неправильной темы: сервер рендерит HTML без знания пользовательских настроек темы, браузер кратко отображает этот HTML, затем React гидратируется и применяет правильную тему — вызывая видимую вспышку.

Решение — блокирующий инлайн-скрипт в <head>, читающий localStorage и устанавливающий атрибут темы до рендеринга страницы:

<!-- В вашем <head>, перед любыми ссылками на CSS -->
<script>
  (function() {
    try {
      var stored = localStorage.getItem('theme');
      var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      var theme = stored === 'dark' || stored === 'light'
        ? stored
        : (systemDark ? 'dark' : 'light');
      document.documentElement.setAttribute('data-theme', theme);
    } catch(e) {}
  })();
</script>

В Next.js это помещается в _document.tsx или в корневой layout.tsx App Router. Поскольку скрипт выполняется синхронно до парсинга CSS, вспышки не возникает.


Tailwind CSS и темы

Tailwind v3: тёмный режим на основе классов

В Tailwind v3 тёмный режим требует установки darkMode: 'class' в tailwind.config.js. Это заставляет Tailwind применять утилиты dark: только при наличии класса .dark на элементе <html>:

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  theme: {
    extend: {
      colors: {
        brand: {
          50: '#EFF6FF',
          100: '#DBEAFE',
          500: '#3B82F6',
          600: '#2563EB',
          700: '#1D4ED8',
          900: '#1E3A8A',
          950: '#172554',
        },
      },
    },
  },
};

Компоненты используют префиксы dark: для вариантов тёмного режима:

<div className="bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100">
  <button className="bg-brand-600 dark:bg-brand-500 text-white hover:bg-brand-700 dark:hover:bg-brand-400">
    Primary Action
  </button>
</div>

Переключатель темы устанавливает класс .dark на document.documentElement — тот же подход, что и с data-атрибутом, только с классом вместо него.

Tailwind v4: CSS-ориентированная конфигурация

Tailwind v4 переносит конфигурацию целиком в CSS, нативно используя CSS-кастомные свойства:

/* styles.css */
@import "tailwindcss";

@theme {
  --color-brand-50: #EFF6FF;
  --color-brand-100: #DBEAFE;
  --color-brand-500: #3B82F6;
  --color-brand-600: #2563EB;
  --color-brand-700: #1D4ED8;
}

Tailwind v4 генерирует утилитарные классы из этих кастомных свойств, и они сразу доступны в JSX: className="bg-brand-600 text-white". Конфигурация тёмного режима в v4 использует @variant dark:

@variant dark (&:where([data-theme="dark"] *)) {
  /* Tailwind применяет утилиты dark: на основе этого селектора */
}

Этот вариант говорит Tailwind применять утилиты dark:, когда у предка есть data-theme="dark", интегрируясь нативно с паттерном data-атрибутов.


Стратегия мультибрендовой тематизации

Задача

SaaS-платформа, обслуживающая нескольких клиентов, white-label продукт или дизайн-система, используемая несколькими продуктовыми брендами, должна обрабатывать не только варианты тёмного/светлого режима, но и совершенно разные цветовые идентичности — каждая со своим основным брендовым цветом, акцентом, цветами статусов и нейтральными.

Брендовые токены как переопределения CSS-переменных

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

/* Базовая тема — одинаковые имена токенов для всех брендов */
:root {
  /* Примитивы бренда — переопределяются для каждого бренда */
  --brand-primary-raw: 37 99 235; /* #2563EB в каналах RGB */
  --brand-accent-raw: 99 102 241; /* #6366F1 в каналах RGB */

  /* Семантические токены — вычисляются из примитивов */
  --brand-primary: rgb(var(--brand-primary-raw));
  --brand-primary-hover: color-mix(in srgb, rgb(var(--brand-primary-raw)) 80%, black);
  --brand-primary-subtle: color-mix(in srgb, rgb(var(--brand-primary-raw)) 10%, white);
}

/* Бренд: Acme Corp (синий) */
[data-brand="acme"] {
  --brand-primary-raw: 37 99 235;   /* #2563EB */
  --brand-accent-raw: 16 185 129;   /* #10B981 */
}

/* Бренд: Globex Inc (пурпурный) */
[data-brand="globex"] {
  --brand-primary-raw: 124 58 237;  /* #7C3AED */
  --brand-accent-raw: 245 158 11;   /* #F59E0B */
}

/* Бренд: Initech (зелёный) */
[data-brand="initech"] {
  --brand-primary-raw: 22 163 74;   /* #16A34A */
  --brand-accent-raw: 239 68 68;    /* #EF4444 */
}

Техника с сырыми каналами RGB (37 99 235) позволяет создавать варианты с прозрачностью без дополнительных токенов:

.overlay {
  background-color: rgba(var(--brand-primary-raw), 0.1);
}

React Context для мультибрендовости

Расширьте контекст темы для обработки брендов:

// contexts/ThemeContext.tsx
type Brand = 'acme' | 'globex' | 'initech' | 'default';
type Theme = 'light' | 'dark';

interface ThemeContextValue {
  theme: Theme;
  brand: Brand;
  setTheme: (theme: Theme) => void;
  setBrand: (brand: Brand) => void;
}

export function ThemeProvider({ initialBrand = 'default', children }: {
  initialBrand?: Brand;
  children: React.ReactNode;
}) {
  const [theme, setTheme] = useState<Theme>('light');
  const [brand, setBrand] = useState<Brand>(initialBrand);

  useEffect(() => {
    const root = document.documentElement;
    root.setAttribute('data-theme', theme);
    root.setAttribute('data-brand', brand);
  }, [theme, brand]);

  return (
    <ThemeContext.Provider value={{ theme, brand, setTheme, setBrand }}>
      {children}
    </ThemeContext.Provider>
  );
}

Устанавливайте начальный бренд из слоя маршрутизации или аутентификации — субдомен каждого клиента или ID тенанта сопоставляется с идентификатором бренда.

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

Для каждого бренда необходима полная цветовая шкала, а не один-два hex-значения. Используйте Генератор оттенков для создания шкалы 50–950 для основного и акцентного цвета каждого бренда. Введите основной hex бренда и получите полный диапазон вариантов от тёмного к светлому.

Для Globex Inc с основным #7C3AED генератор создаёт полную пурпурную шкалу. Для Initech с #16A34A — полную зелёную шкалу. Эти шкалы затем наполняют примитивные токены для каждого бренда, гарантируя, что --brand-primary-subtle (светлейший тинт) и --brand-primary-hover (более тёмное нажатое состояние) остаются гармонически последовательными в рамках каждой брендовой идентичности.


Ключевые выводы

  • CSS-кастомные свойства — основа: определяйте семантические токены (--text-primary, --brand-primary) и изменяйте их значения для каждой темы, а не условно применяйте классы в каждом компоненте.
  • Переключение темы через data-атрибут (data-theme="dark") — наиболее гибкий паттерн: он переопределяется JavaScript, совместим с SSR и сочетается с медиазапросами prefers-color-scheme.
  • Двухуровневая система токенов (примитивная цветовая шкала + семантические токены ролей) обеспечивает полную гибкость темы при сохранении чистоты компонентов: примитивы определяют палитру, семантика — способ использования цветов.
  • Используйте Генератор оттенков для создания полной цветовой шкалы 50–950 из фирменного основного цвета — это даёт полный диапазон светлых и тёмных вариантов, необходимых для полноценной системы токенов.
  • React Context управляет состоянием темы (именем активной темы) и побочными эффектами (установка data-атрибута, сохранение в localStorage); компонентам никогда не нужно читать контекст для применения правильных цветов.
  • Предотвращайте вспышку неправильной темы с помощью блокирующего инлайн-скрипта в <head>, читающего localStorage и устанавливающего атрибут темы до рендеринга браузером.
  • Интеграция Tailwind CSS: v3 использует darkMode: 'class' с префиксами dark:; v4 использует CSS-ориентированную конфигурацию @theme с нативной поддержкой CSS-переменных.
  • Мультибрендовая тематизация добавляет ещё один data-атрибут (data-brand) поверх системы тем, переопределяя значения примитивных токенов для каждого бренда при сохранении семантических токенов и компонентов неизменными.

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

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

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