Оформление — тема, бренд, плотность
Дизайн-система разделяет оформление на два слоя:
- Оси — в React-контексте. Значения
colorScheme,brand,brandRole,density,acrylicживут в контексте@cloud-ru/ds-theme, а не в пропах каждого компонента. Источник — одинRootThemeProviderв корне приложения. - Классы — на DOM-границе. Из осей контекста на границу провайдера эмитится полный набор CSS-классов
sn-*из@cloud-ru/figma-variables. Контракт значения контекста (пять осей) не растёт с числом компонентов, поэтому он устойчив к версиям в микрофронтах (MFE) и безопасен для SSR.
Поверх этого приложение может переопределить любую ось в поддереве (ChildThemeProvider), зафиксировать ось в одном компоненте (useThemeClassnames) и управлять цветовой схемой light/dark без моргания на загрузке (useColorScheme + SSR/bootstrap).
Полный набор
sn-*обязателен на каждой границе: токены@cloud-ru/figma-variablesне переопределяются по одной оси через CSS-каскад. Поэтому нельзя поставить одиночныйsn-comfortруками — для этого есть провайдеры и хук, которые эмитят набор целиком. СтарыйThemeProvider/useThemeConfig(произвольные темы поthemeMap) — отдельный механизм, не путать сRootThemeProvider(см. раздел «Легаси» ниже).
Кратко
import { ChildThemeProvider, getGlobalThemeStore, RootThemeProvider, useColorScheme } from '@cloud-ru/ds-theme';
// Один React-корень (SSR): оси пропом. Цветовая схема — источник истины useColorScheme.
function App({ children }: { children: ReactNode }) {
const { colorScheme } = useColorScheme();
return (
<RootThemeProvider value={{ colorScheme, brand: 'brandA', density: 'comfort' }} rootRef={htmlRef}>
{children}
</RootThemeProvider>
);
}
// Микрофронты: оси из общего стора, хост-контейнер переключает их одним вызовом.
<RootThemeProvider store={getGlobalThemeStore().store} rootRef={htmlRef}>{mfe}</RootThemeProvider>;
getGlobalThemeStore().setAppearance({ colorScheme: 'dark' });
// Часть дерева в другом бренде — относительное переопределение поверх контекста.
<ChildThemeProvider value={{ brand: 'brandB' }}>{section}</ChildThemeProvider>;
rootRef — внешний элемент (обычно <html>/<body>), на который ставится полный набор sn-*. Без него провайдер оборачивает children в собственный <div> с этим набором.
Оси оформления
Все оси объявлены в constants.ts и спроецированы на классы sn-*. Тип значения — ThemeAppearance (все поля опциональны; незаданные наследуются от родителя).
| Ось | Константа | Значения → класс |
|---|---|---|
| Цветовая схема | COLOR_SCHEME | light → sn-light, dark → sn-dark |
| Бренд | BRAND | brandA / brandB / brandC / brandD / brandE → sn-brandA … |
| Роль бренда (палитра) | BRAND_ROLE | main / alter / alter2…4 → sn-main … |
| Плотность | DENSITY | comfort / compact / spacious → sn-comfort … |
| Акрил (blur-материал) | — (boolean) | true → sn-yes, false → sn-no |
Базовые слои (sn-base-styles, sn-figmaStyles, sn-components) эмитятся всегда — вместе с осями они образуют полный набор на границе.
Как эмитятся классы
Полный набор собирается из осей одной чистой функцией и ставится на DOM-границу:
getThemeClassnameList(appearance)— список токеновsn-*(источник истины). Нужен дляelement.classList.add(...), которому требуются отдельные классы.getThemeClassnames(appearance)— та же форма строкой (черезjoin). Используется на SSR (класс на<html>) и как основа хукаuseThemeClassnames.useThemeClassnames(overrides?)— хук: берёт ближайшее оформление из контекста, накладываетoverridesи возвращает строку классов. Навешивается на DOM-границу компонента, который фиксирует ось у себя.
Внутри провайдеров эту работу делает ThemeScope: в режиме rootRef добавляет/снимает классы на внешнем элементе через эффект, иначе рендерит wrapper-<div> с набором.
Провайдер: оси пропом или из стора
RootThemeProvider задаёт оформление абсолютно (с нуля). Источник значения — один из двух пропов:
- Один React-корень (Next SSR) —
valueпропом. Оформление ограничивается рендером/запросом и между запросами не утекает. - Несколько корней (single-spa) —
store={getGlobalThemeStore().store}. Хост-контейнер переключает оформление одним вызовомsetAppearance, все подписанные провайдеры во всех микрофронтах перерисовываются без перерендера самого провайдера. - Без провайдера.
RootThemeProviderопционален: дефолт контекста — глобальный стор оформления (getGlobalThemeStore). Компонент, вызывающийuseThemeClassnamesбез провайдера в своём корне (мобильная обёртка, портал вне<body>), реэмитит набор не с пустым оформлением, а с тем, что задал хост-контейнер. Провайдер монтируется, когда нужно статическое оформление, собственный реактивный источник или явная DOM-граница.
// хост-контейнер, один раз при смене оформления:
getGlobalThemeStore().setAppearance({ colorScheme: 'dark', density: 'compact' });
// в каждом независимом корне:
<RootThemeProvider store={getGlobalThemeStore().store} rootRef={htmlRef}>{root}</RootThemeProvider>;
setAppearance принимает Partial<ThemeAppearance> и сливает патч с текущим состоянием — переданные оси меняются, остальные сохраняются.
Локальные переопределения
Часть дерева может иметь другое оформление. Два инструмента — по тому, что именно переопределяется.
ChildThemeProvider — относительное переопределение поддерева
В отличие от RootThemeProvider (абсолютное значение), ChildThemeProvider задаёт оформление относительно: читает ближайшего родителя и сливает поверх свои value (Partial<ThemeAppearance>). Незаданные оси наследуются, а не сбрасываются.
// Всё приложение — brandA / comfort; одна секция — brandB, остальные оси от родителя.
<RootThemeProvider value={{ brand: 'brandA', density: 'comfort', colorScheme }}>
<App />
<ChildThemeProvider value={{ brand: 'brandB' }}>
{/* brand=brandB, density=comfort и colorScheme унаследованы */}
<PromoSection />
</ChildThemeProvider>
</RootThemeProvider>;
ChildThemeProvider — read-only: глобально менять оформление он не может (это умеет только RootThemeProvider / getGlobalThemeStore). Поставить в поддереве второй RootThemeProvider нельзя — он сбросил бы унаследованные оси в пусто и потерял реактивность глобальной темы.
useThemeClassnames — компонент фиксирует ось у себя
Когда ось фиксирует сам компонент (мобильная обёртка всегда в comfort), он не оборачивает себя в провайдер, а навешивает классы напрямую:
import { useThemeClassnames } from '@cloud-ru/ds-theme';
function MobileSheet({ children }: { children: ReactNode }) {
// density=comfort фиксирован; colorScheme/brand подмешиваются из контекста — scope самосогласован.
const className = useThemeClassnames({ density: 'comfort' });
return <div className={className}>{children}</div>;
}
Цветовая схема light/dark
useColorScheme — источник истины для схемы в приложении. Резолвит выбор пользователя поверх системной темы и держит класс sn-light/sn-dark на корне.
override— выбор пользователя:light/dark/system(следоватьprefers-color-scheme).colorScheme— итог: явныйlight/darkпобеждает;systemрезолвится по системной теме (resolveColorScheme). Поэтому «всегда светлая» остаётся светлой даже на тёмном устройстве.setOverride(next)— сменить выбор (персистится, только если подключёнstorage-адаптер; по умолчанию — in-memory, см. ниже).
import { createCookieColorSchemeStorage, useColorScheme } from '@cloud-ru/ds-theme';
const themeStorage = createCookieColorSchemeStorage();
function ThemeToggle() {
const { override, colorScheme, setOverride } = useColorScheme({ storage: themeStorage });
// override → подсветка переключателя; colorScheme → передаётся в RootThemeProvider value.
return <SegmentControl value={override} onChange={setOverride} items={['light', 'dark', 'system']} />;
}
По умолчанию персиста нет: useColorScheme держит выбор in-memory (переживает ремаунты, синхронит несколько переключателей в пределах сессии, но не сохраняется между перезагрузками). DS намеренно не пишет в cookie/localStorage сам — хранилище подключает приложение через проп storage (ColorSchemeStorage):
createCookieColorSchemeStorage()— cookie +BroadcastChannel. Единственный готовый адаптер: нужен для no-flash SSR, потому что cookie читается и на сервере (SSR-резолв), и синхронно inline-bootstrap’ом.- Свой адаптер (localStorage / бэкенд-сессия) — реализует тот же контракт из трёх методов:
read()(синхронно отдать override; на сервере —undefined, начальное даётinitialOverride),write()(персист / POST),subscribe()(кросс-таб / push через SSE/ws).
Контракт намеренно про одну ось — override цветовой схемы; brand/density/прочие оси персистит сам апп (пример — themeStore портала документации, который держит все оси в localStorage). Любой адаптер реагирует на живое переключение темы ОС (matchMedia) и на внешние изменения (storage.subscribe). Модель хука от выбора адаптера не зависит.
Без моргания: SSR и bootstrap
Чтобы тёмная тема не «моргала» светлой до гидрации, корректный класс должен стоять на корне до первой отрисовки. Этот сценарий требует cookie-персиста (createCookieColorSchemeStorage на клиенте) — три точки работают вместе и читают одну cookie (snack-uikit-theme):
- SSR-класс.
getColorSchemeFromHeaders(headers)резолвит итоговую схему из cookiesnack-uikit-theme(override) и client hintSec-CH-Prefers-Color-Scheme(системная тема) — результат ставится классом на<html>при рендере на сервере. - Детерминированный первый рендер.
getThemeOverrideFromHeaders(headers)отдаёт override из cookie; передаётся вuseColorScheme({ initialOverride }). Первый клиентский рендер совпадает с SSR — без hydration mismatch и без прыжка подсветки переключателя. - Inline-bootstrap.
getThemeBootstrapScript()возвращает строку синхронного скрипта для<head>(до<body>): читает cookie +prefers-color-schemeи ставитsn-light/sn-darkещё до отрисовки. Вставляется через<script dangerouslySetInnerHTML>(Next) или статический<script>(single-spa).
// SSR (Next App Router): класс на <html> + детерминированный override клиенту.
import { getColorSchemeFromHeaders, getThemeBootstrapScript, getThemeOverrideFromHeaders } from '@cloud-ru/ds-theme/ssr';
const scheme = getColorSchemeFromHeaders(headers()); // 'light' | 'dark'
const initialOverride = getThemeOverrideFromHeaders(headers());
<html className={scheme === 'dark' ? 'sn-dark' : 'sn-light'}>
<head>
<script dangerouslySetInnerHTML={{ __html: getThemeBootstrapScript() }} />
</head>
<body>{children}</body>
</html>;
// клиент (no-flash требует cookie-адаптера — ту же cookie читает SSR и bootstrap):
const { colorScheme } = useColorScheme({ initialOverride, storage: createCookieColorSchemeStorage() });
Client hint Sec-CH-Prefers-Color-Scheme нужно включить ответными заголовками Accept-CH / Critical-CH, иначе на первом визите хинта нет (системная вернётся light), а корректную тему доставит inline-bootstrap до отрисовки.
Своё имя ключа
Имя cookie (snack-uikit-theme по умолчанию, константа THEME_OVERRIDE_STORAGE_KEY) переопределяется опцией storageKey — у cookie-адаптера и у всех SSR/bootstrap-хелперов:
createCookieColorSchemeStorage({ storageKey })getColorSchemeFromHeaders(headers, { storageKey })getThemeOverrideFromHeaders(headers, { storageKey })getThemeBootstrapScript({ storageKey })
Эти точки читают одну и ту же cookie, поэтому ключ переопределяется согласованно во всех сразу — иначе SSR, bootstrap и клиент прочитают разные cookie, и вернётся моргание / hydration mismatch. Один ключ задаётся константой приложения:
const STORAGE_KEY = 'my-app-theme';
// клиент
useColorScheme({ initialOverride, storage: createCookieColorSchemeStorage({ storageKey: STORAGE_KEY }) });
// SSR
getColorSchemeFromHeaders(headers(), { storageKey: STORAGE_KEY });
getThemeOverrideFromHeaders(headers(), { storageKey: STORAGE_KEY });
// bootstrap
getThemeBootstrapScript({ storageKey: STORAGE_KEY });
У самого useColorScheme опции storageKey нет: дефолтный адаптер in-memory ключа не использует, а ключ живёт только там, где реально пишется/читается хранилище (адаптер + SSR-хелперы). Свой не-cookie адаптер (localStorage / бэкенд) сам решает, под каким ключом хранить.
MFE и SSR
- MFE. Объект контекста оформления —
Symbol.for-синглтон (providerKey('theme-appearance', 1)): потребитель в микрофронте читает ближайший провайдер независимо от версии пакета. ГлобальныйgetGlobalThemeStore()(тожеSymbol.for) даёт один источник оформления на все корни и служит дефолтом контекста — поэтому MFE безRootThemeProviderвсё равно эмитит корректные классы. Контракт значения (пять осей) не растёт с числом компонентов — ключ остаётсяv1. - SSR.
getServerSnapshotстора отдаёт пустое оформление (мутабельный глобал на сервере запрещён — утечёт между запросами). Стартовое оформление на сервере задаёт сам app:valueпропомRootThemeProviderлибо SSR-класс изgetColorSchemeFromHeaders. - RSC-safe субпуть.
@cloud-ru/ds-theme/ssrэкспортирует только чистые символы (константы, типы,getThemeClassnames,resolveColorScheme,getColorSchemeFromHeaders,getThemeBootstrapScript) — без React-контекста и хуков, поэтому при импорте в React Server ComponentscreateContextне загружается.
Легаси: ThemeProvider / useThemeConfig
ThemeProvider и useThemeConfig (themeMap / changeTheme) — отдельный механизм для произвольных пользовательских тем по соответствию «имя темы → CSS-класс». Он не связан с осями DS (colorScheme/brand/density/…) и эмитит один класс из themeMap, а не полный набор sn-*.
Для оформления дизайн-системы используется RootThemeProvider, не ThemeProvider. Старый API сохранён для обратной совместимости — не путать их в одном дереве.
Частые проблемы
- Классы
sn-*не на корне / тема не применилась.RootThemeProviderбезrootRefставит набор на собственный wrapper-<div>, а не на<html>. Для глобального оформления передайтеrootRefна корневой элемент. Если провайдера нет вовсе — оформление берётся изgetGlobalThemeStore; проверьте, что хост-контейнер вызвалsetAppearance(...). - Моргание light/dark на reload. Нет inline
getThemeBootstrapScriptв<head>или нет SSR-класса на<html>. Корректный класс должен стоять до первой отрисовки — добавьте обе точки. - Hydration mismatch цветовой схемы.
useColorSchemeбезinitialOverrideна SSR: сервер и клиент стартуют с разного override. ПередайтеgetThemeOverrideFromHeaders(headers)вinitialOverride. - Одиночный
sn-comfortруками не действует. Токены не переопределяются по одной оси через каскад — нужен полный набор. ИспользуйтеChildThemeProvider value={{ density: 'comfort' }}илиuseThemeClassnames({ density: 'comfort' }). - Несколько копий
@cloud-ru/ds-theme. На работу оформления это не влияет: контекст и стор —Symbol.for-синглтоны, общие между копиями. При смешанных contract-версиях одного домена — только dev-warn; зафиксируйте единый мажор провайдера.
Смотри также
- Theme — справочник API пакета и живые примеры.
- Локализация — строки в пакетах — та же двухслойная модель для строк.
- Adaptive — раскладка
layoutType(источникdensityв приложении). - PortalContext — корневой DOM-узел для порталов.