Theme

@cloud-ru/ds-theme управляет оформлением дизайн-системы. Оси оформления живут в React-контексте, а полный набор CSS-классов sn-* эмитится на DOM-границу. RootThemeProvider ставится один раз в корне и держит оси (colorScheme, brand, brandRole, density, acrylic), эмитя из них полный набор sn-* на rootRef (обычно <html>). Локальные переопределения в поддереве делает ChildThemeProvider, а компонент, фиксирующий ось у себя, — хук useThemeClassnames.

Кроме предустановленных брендов @cloud-ru/ds-theme умеет собрать бренд-палитру из одного seed-цвета (white-label): из него генерируется полная шкала тонов --sn-brand-color-primary-*, и весь семантический слой каскадит из неё — см. секцию «Кастомный бренд-цвет».

Полное руководство по модели и подключению — в паттерне Оформление — тема, бренд, плотность.

Когда использовать

  • Хост-приложение задаёт оформление в корне: RootThemeProvider value={{ colorScheme, brand, density }} (или store для multi-root / MFE через getGlobalThemeStore().store).
  • Цветовая схема light/dark берётся из useColorScheme (prefers-color-scheme + опциональный персист-адаптер) и передаётся в value.colorScheme.
  • Часть дерева должна иметь другой бренд / плотность / схему — ChildThemeProvider сливает partial-переопределение с ближайшим контекстом.
  • Компонент локально фиксирует ось (мобильная обёртка с density: 'comfort') — useThemeClassnames({ density }) подмешивает текущие colorScheme/brand из контекста.
  • Нужен бренд-цвет вне предустановленного набора (white-label под клиента) — RootThemeProvider brandColor='#RRGGBB' (декларативно) либо хук useApplyCustomTheme({ color }) (императивно).

Полный набор sn-* обязателен на каждой границе: классы токенов не переопределяются по одной оси через CSS-каскад. Никогда не ставьте одиночный sn-comfort руками — для этого есть провайдеры и хук. Старый ThemeProvider / useThemeConfig (произвольные темы по themeMap) — отдельный legacy-механизм, не путать с RootThemeProvider.

Установка

pnpm add @cloud-ru/ds-theme
import {
  RootThemeProvider,
  ChildThemeProvider,
  useThemeClassnames,
  useColorScheme,
  useApplyCustomTheme,
  getGlobalThemeStore,
} from '@cloud-ru/ds-theme'

Примеры использования

Бренд для поддерева

Бренд для поддерева`ChildThemeProvider` сливает ось `brand` с ближайшим контекстом и реэмитит полный набор `sn-*` на своей границе.
Бренд
8
tsx
import { Block } from '@cloud-ru/ds-block';
import { Button } from '@cloud-ru/ds-button';
import { Counter } from '@cloud-ru/ds-counter';
import { SegmentControl } from '@cloud-ru/ds-segment-control';
import { Tag } from '@cloud-ru/ds-tag';
import { BRAND, Brand, ChildThemeProvider } from '@cloud-ru/ds-theme';
import { Flex } from '@cloud-ru/ds-uikit-product-flex';
import { useState } from 'react';

const BRAND_ITEMS = Object.values(BRAND).map(value => ({ value, label: value }));

export function BrandSwitch() {
  const [brand, setBrand] = useState<Brand>(BRAND.A);

  return (
    <Flex direction='column' gap='2m' align='flex-start'>
      <SegmentControl items={BRAND_ITEMS} value={brand} onChange={value => setBrand(value as Brand)} />

      {/* ChildThemeProvider сливает ось `brand` с ближайшим контекстом и реэмитит полный набор
          sn-* на своей границе — акцентные цвета компонентов ниже меняются вслед за брендом. */}
      <ChildThemeProvider value={{ brand }}>
        <Block>
          <Flex gap='2m' align='center' wrap>
            <Button appearance='primary' label='Действие' />
            <Tag appearance='primary' label='Бренд' />
            <Counter value={8} appearance='primary' />
          </Flex>
        </Block>
      </ChildThemeProvider>
    </Flex>
  );
}

Переключение цветовой схемы

Переключение цветовой схемыПоддерево рендерится в выбранной схеме (`sn-light` / `sn-dark`). В приложении схема — источник истины `useColorScheme`.
Метка
tsx
import { Block } from '@cloud-ru/ds-block';
import { Button } from '@cloud-ru/ds-button';
import { SegmentControl } from '@cloud-ru/ds-segment-control';
import { Tag } from '@cloud-ru/ds-tag';
import { ChildThemeProvider, COLOR_SCHEME, ColorScheme } from '@cloud-ru/ds-theme';
import { Flex } from '@cloud-ru/ds-uikit-product-flex';
import { useState } from 'react';

const SCHEME_ITEMS = [
  { value: COLOR_SCHEME.Light, label: 'Светлая' },
  { value: COLOR_SCHEME.Dark, label: 'Тёмная' },
];

export function ColorSchemeToggle() {
  const [colorScheme, setColorScheme] = useState<ColorScheme>(COLOR_SCHEME.Light);

  return (
    <Flex direction='column' gap='2m' align='flex-start'>
      <SegmentControl
        items={SCHEME_ITEMS}
        value={colorScheme}
        onChange={value => setColorScheme(value as ColorScheme)}
      />

      {/* В приложении colorScheme — источник истины `useColorScheme` (cookie + prefers-color-scheme),
          а корень держит RootThemeProvider. Здесь ChildThemeProvider переключает схему для поддерева:
          материал-подложка Block и компоненты на ней перекрашиваются вслед за схемой. */}
      <ChildThemeProvider value={{ colorScheme }}>
        <Block>
          <Flex gap='2m' align='center' wrap>
            <Button appearance='primary' label='Действие' />
            <Button appearance='neutral' view='outline' label='Отмена' />
            <Tag appearance='blue' label='Метка' />
          </Flex>
        </Block>
      </ChildThemeProvider>
    </Flex>
  );
}

Локальная плотность

Локальная плотностьКомпонент фиксирует `density` через `useThemeClassnames({ density })` — хук подмешивает текущие colorScheme/brand из контекста.
Тег
tsx
import { Button } from '@cloud-ru/ds-button';
import { SegmentControl } from '@cloud-ru/ds-segment-control';
import { Tag } from '@cloud-ru/ds-tag';
import { DENSITY, Density, useThemeClassnames } from '@cloud-ru/ds-theme';
import { Flex } from '@cloud-ru/ds-uikit-product-flex';
import { useState } from 'react';

const DENSITY_ITEMS = Object.values(DENSITY).map(value => ({ value, label: value }));

function DensitySurface({ density }: { density: Density }) {
  // useThemeClassnames({ density }) подмешивает текущие colorScheme/brand из контекста и навешивает
  // ПОЛНЫЙ набор sn-* (а не одиночный sn-comfort) — внутренние отступы компонентов меняются вслед
  // за плотностью, а тёмная тема при этом не ломается.
  const className = useThemeClassnames({ density });

  return (
    <div className={className}>
      <Flex gap='2m' align='center' wrap>
        <Button appearance='primary' label='Кнопка' />
        <Button appearance='neutral' view='outline' label='Ещё' />
        <Tag appearance='primary' label='Тег' />
      </Flex>
    </div>
  );
}

export function LocalDensity() {
  const [density, setDensity] = useState<Density>(DENSITY.Compact);

  return (
    <Flex direction='column' gap='2m' align='flex-start'>
      <SegmentControl items={DENSITY_ITEMS} value={density} onChange={value => setDensity(value as Density)} />
      <DensitySurface density={density} />
    </Flex>
  );
}

Кастомный бренд-цвет

Помимо предустановленных брендов (brandA / brandB / brandC / brandD / brandE) @cloud-ru/ds-theme собирает бренд-палитру из одного seed-цвета — для white-label под клиента. Из seed генерируется полная шкала тонов --sn-brand-color-primary-* (OKLCH: светлота и насыщенность берутся из опорной шкалы, hue поворачивается к seed) плюс activated-тинты; семантический слой --sn-theme-color-primary-* каскадит из неё. Поэтому один цвет перекрашивает акцент во всех компонентах — и в светлой, и в тёмной схеме.

Палитра применяется CSS-правилом на бренд-классы (.sn-brandA/B/C/D/E), а не inline-переменными на одном элементе. Это принципиально: компоненты, переобъявляющие полный набор sn-* на своих внутренних обёртках (Table, Stepper и т.п. через useThemeClassnames), заново объявляют бренд-палитру из класса — inline-переменные предка в таких поддеревьях перекрываются, а правило на том же бренд-классе — нет. Два способа применить:

  • Декларативно — проп brandColor у RootThemeProvider. Добавляет scoped-правило на бренд-классы поддерева провайдера (доходит до вложенных переобъявлений):

    <RootThemeProvider value={{ colorScheme }} brandColor={brand.primaryColor}>
      {app}
    </RootThemeProvider>
  • Императивно — хук useApplyCustomTheme. Без scope добавляет глобальное правило на все бренд-классы страницы (покрывает и порталы — дропдауны, тултипы); со scope ограничивает поддеревом. Удобно, когда бренд-цвет приходит асинхронно из бэкенда в bootstrap-компоненте:

    useApplyCustomTheme({ color: brand.primaryColor, enabled: Boolean(brand), nonce })

    Порталы монтируются вне поддерева провайдера, поэтому для их перекраски используйте глобальный useApplyCustomTheme в корне приложения (без scope), а не scoped-brandColor.

Схему (light/dark) кастомный бренд-цвет не задаёт — она остаётся из colorScheme, а палитра тонов от схемы не зависит. Для SSR без мигания палитру можно собрать строкой заранее: generateBrandPalette(color) / buildBrandPaletteVars(color) из @cloud-ru/ds-theme/ssr (чистые, без React и DOM).

Бренд-цвет из seed

Бренд-цвет из seed`brandColor` генерирует палитру `--sn-brand-color-primary-*` из одного цвета — акцент компонентов перекрашивается вслед за выбором.
Бренд
8
tsx
import { Block } from '@cloud-ru/ds-block';
import { Button } from '@cloud-ru/ds-button';
import { Counter } from '@cloud-ru/ds-counter';
import { SegmentControl } from '@cloud-ru/ds-segment-control';
import { Tag } from '@cloud-ru/ds-tag';
import { RootThemeProvider } from '@cloud-ru/ds-theme';
import { Flex } from '@cloud-ru/ds-uikit-product-flex';
import { useState } from 'react';

const COLOR_ITEMS = [
  { value: '#ff7a00', label: 'Оранжевый' },
  { value: '#8a2be2', label: 'Фиолетовый' },
  { value: '#0077ff', label: 'Синий' },
  { value: '#e5006e', label: 'Розовый' },
];

export function CustomBrandColor() {
  const [color, setColor] = useState('#ff7a00');

  return (
    <Flex direction='column' gap='2m' align='flex-start'>
      <SegmentControl items={COLOR_ITEMS} value={color} onChange={value => setColor(String(value))} />

      {/* brandColor генерирует палитру `--sn-brand-color-primary-*` из одного seed-цвета — акцент
          компонентов ниже перекрашивается вслед за выбором. */}
      <RootThemeProvider value={{ colorScheme: 'light', brand: 'brandA', brandRole: 'main' }} brandColor={color}>
        <Block>
          <Flex gap='2m' align='center' wrap>
            <Button appearance='primary' label='Действие' />
            <Tag appearance='primary' label='Бренд' />
            <Counter value={8} appearance='primary' />
          </Flex>
        </Block>
      </RootThemeProvider>
    </Flex>
  );
}

Императивный хук со scope

Императивный хук со scope`useApplyCustomTheme({ color, scope })` инжектит правило, скоупленное на `#brand-hook-scope` — результат применяется ко всем компонентам внутри поддерева. Без `scope` правило глобальное (покрывает и порталы).
Бренд
8
tsx
import { Block } from '@cloud-ru/ds-block';
import { Button } from '@cloud-ru/ds-button';
import { Counter } from '@cloud-ru/ds-counter';
import { SegmentControl } from '@cloud-ru/ds-segment-control';
import { Tag } from '@cloud-ru/ds-tag';
import { RootThemeProvider, useApplyCustomTheme } from '@cloud-ru/ds-theme';
import { Flex } from '@cloud-ru/ds-uikit-product-flex';
import { useState } from 'react';

const COLOR_ITEMS = [
  { value: '#8a2be2', label: 'Фиолетовый' },
  { value: '#ff7a00', label: 'Оранжевый' },
  { value: '#0077ff', label: 'Синий' },
];

// `scope` — CSS-селектор корня поддерева. Здесь правило скоуплено на `#brand-hook-scope`, поэтому
// бренд-акцент перекрашивается только внутри этого блока (и во всех компонентах ниже по дереву — Button,
// Tag, Counter). Без `scope` правило было бы глобальным и перекрасило бы всю страницу, включая порталы.
const SCOPE_ID = 'brand-hook-scope';

export function CustomBrandColorHook() {
  const [color, setColor] = useState('#8a2be2');

  useApplyCustomTheme({ color, scope: `#${SCOPE_ID}` });

  return (
    <Flex direction='column' gap='2m' align='flex-start'>
      <SegmentControl items={COLOR_ITEMS} value={color} onChange={value => setColor(String(value))} />

      <div id={SCOPE_ID}>
        <RootThemeProvider value={{ colorScheme: 'light', brand: 'brandA', brandRole: 'main' }}>
          <Block>
            <Flex gap='2m' align='center' wrap>
              <Button appearance='primary' label='Внутри scope' />
              <Tag appearance='primary' label='Бренд' />
              <Counter value={8} appearance='primary' />
            </Flex>
          </Block>
        </RootThemeProvider>
      </div>
    </Flex>
  );
}

Props

RootThemeProvider

Types

PropsRootThemeProviderProps
PropTypeDefaultRequiredDescription
brandColorstringnoКастомный бренд-цвет потребителя (hex `#rrggbb`) для white-label. Генерирует бренд-палитру из seed-цвета и инжектит scoped `<style>` на бренд-классы этого поддерева. Правило на бренд-классе (а не inline на одном элементе) переживает переэмиты `sn-*` вложенными компонентами (Table и т.п.), поэтому кастомный цвет доходит до всей вложенности. Невалидный hex игнорируется. Глобальная (app-root) альтернатива для порталов — хук `useApplyCustomTheme`.
childrenstring | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | null | undefinedyes
classNamestringnoДополнительный класс на wrapper-`<div>` (паддинги/фон). Только в wrapper-режиме (без `rootRef`).
noncestringnoCSP-`nonce` для инжектируемого `<style>` кастомного бренд-цвета.
rootRefRefObject<HTMLElement | null>noВнешний элемент для полного набора `sn-*` (обычно `<html>`/`<body>`). Если не задан — провайдер оборачивает children в `<div>` с этим набором.
storeThemeAppearanceStorenoВнешний реактивный стор оформления (`getGlobalThemeStore().store`). Если задан — приоритетнее `value`; подписанные провайдеры обновляются при смене темы без перерендера провайдера. Так один глобальный стор охватывает все микрофронты. Сеттер для shell — `getGlobalThemeStore().setAppearance`.
valueThemeAppearancenoОформление приложения. Используется в static-режиме (один React-корень: SSR — одно значение на запрос, либо CSR с собственным state, напр. `colorScheme` из `useColorScheme`). Для multi-root (single-spa) — см. `store`.

Types

RootThemeProviderProps

ChildThemeProvider

Types

PropsChildThemeProviderProps
PropTypeDefaultRequiredDescription
childrenstring | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | null | undefinedyes
classNamestringnoДополнительный класс на wrapper-`<div>` (паддинги/фон). Только в wrapper-режиме (без `rootRef`).
rootRefRefObject<HTMLElement | null>noВнешний элемент для полного слитого набора `sn-*`. Если не задан — провайдер оборачивает children в `<div>` с этим набором.
valuePartial<ThemeAppearance>yesОси, переопределяемые в поддереве. Остальные наследуются от ближайшего родителя (слияние).

Types

ChildThemeProviderProps

Storybook

Смотри также