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'
Примеры использования
Бренд для поддерева
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>
);
}Переключение цветовой схемы
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>
);
}Локальная плотность
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
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
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
RootThemeProviderProps| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
brandColor | string | — | no | Кастомный бренд-цвет потребителя (hex `#rrggbb`) для white-label. Генерирует бренд-палитру из seed-цвета и инжектит scoped `<style>` на бренд-классы этого поддерева. Правило на бренд-классе (а не inline на одном элементе) переживает переэмиты `sn-*` вложенными компонентами (Table и т.п.), поэтому кастомный цвет доходит до всей вложенности. Невалидный hex игнорируется. Глобальная (app-root) альтернатива для порталов — хук `useApplyCustomTheme`. |
children | string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | null | undefined | — | yes | |
className | string | — | no | Дополнительный класс на wrapper-`<div>` (паддинги/фон). Только в wrapper-режиме (без `rootRef`). |
nonce | string | — | no | CSP-`nonce` для инжектируемого `<style>` кастомного бренд-цвета. |
rootRef | RefObject<HTMLElement | null> | — | no | Внешний элемент для полного набора `sn-*` (обычно `<html>`/`<body>`). Если не задан — провайдер оборачивает children в `<div>` с этим набором. |
store | ThemeAppearanceStore | — | no | Внешний реактивный стор оформления (`getGlobalThemeStore().store`). Если задан — приоритетнее `value`; подписанные провайдеры обновляются при смене темы без перерендера провайдера. Так один глобальный стор охватывает все микрофронты. Сеттер для shell — `getGlobalThemeStore().setAppearance`. |
value | ThemeAppearance | — | no | Оформление приложения. Используется в static-режиме (один React-корень: SSR — одно значение на запрос, либо CSR с собственным state, напр. `colorScheme` из `useColorScheme`). Для multi-root (single-spa) — см. `store`. |
Types
RootThemeProviderProps
ThemeAppearance
ThemeAppearanceStore
ChildThemeProvider
Types
ChildThemeProviderProps| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
children | string | number | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | null | undefined | — | yes | |
className | string | — | no | Дополнительный класс на wrapper-`<div>` (паддинги/фон). Только в wrapper-режиме (без `rootRef`). |
rootRef | RefObject<HTMLElement | null> | — | no | Внешний элемент для полного слитого набора `sn-*`. Если не задан — провайдер оборачивает children в `<div>` с этим набором. |
value | Partial<ThemeAppearance> | — | yes | Оси, переопределяемые в поддереве. Остальные наследуются от ближайшего родителя (слияние). |
Types
ChildThemeProviderProps
ThemeAppearance
Storybook
Смотри также
- Оформление — тема, бренд, плотность — полная модель подключения темы.
- Adaptive — раскладка
layoutType(источникdensityв приложении). - Locale — рантайм локализации.
- PortalContext — корневой DOM-узел для порталов.