Theming Ant Design v5 with ConfigProvider Tokens: An Architecture Note
An architecture note on Ant Design v5 token theming: one root ConfigProvider, algorithm-based dark mode, validation for tenant-supplied tokens, and failure modes.
26 Jun 2026, 07:43 UTC

Ant Design v5 replaced the v4 less-variable build-time theming with a runtime CSS-in-JS engine (@ant-design/cssinjs). Themes are now plain JavaScript objects passed to ConfigProvider's theme prop. That shift changes the architecture decision: instead of asking "how do we compile a custom less build," you ask "where do tokens live, who can influence them, and how do we verify they apply consistently." This note covers that decision for a typical single-brand app with light/dark switching, plus the cases where the minimal design stops being enough.
Assumptions: antd 5.x, React 18, a client-rendered or SSR app. Token names and algorithm exports have shifted between 5.x minors, so confirm against your installed version's typings before copying keys.
Requirements
The common requirement set looks like this:
- One brand theme (primary color, border radius, font) applied to every antd component.
- Light and dark modes, switchable at runtime, persisted per user.
- No flash of the wrong theme on first paint.
- Optionally, per-tenant brand colors supplied by an API — which introduces a trust boundary most guides skip.
The smallest suitable design
One ConfigProvider at the app root. Nothing else. Resist the urge to nest providers per page or per feature — every nested provider re-generates styles for its subtree and creates a place where tokens can silently diverge.
import { ConfigProvider, theme } from 'antd';
const baseTokens = {
token: {
colorPrimary: '#1677ff',
borderRadius: 6,
},
components: {
Table: { headerBg: '#fafafa' },
},
};
function App() {
const [mode, setMode] = useState<'light' | 'dark'>(
() => (localStorage.getItem('theme-mode') as 'light' | 'dark') ?? 'light'
);
return (
<ConfigProvider
theme={{
...baseTokens,
algorithm:
mode === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<RootLayout mode={mode} onModeChange={setMode} />
</ConfigProvider>
);
}Key points:
- Algorithms, not token swaps. For dark mode, toggle
theme.darkAlgorithmvstheme.defaultAlgorithmon the same root provider. The algorithm derives the full palette (backgrounds, borders, text colors) from your seed tokens, so you don't hand-maintain a parallel dark token set. - Read the persisted preference before first render. Initialize state from
localStoragein the state initializer, not in an effect. An effect runs after first paint and produces a visible flash of the default theme. - Component-level overrides go under
theme.componentswhen a token change should apply to one component only. Keep these few; each one is a deviation future maintainers must know about.
Trust and data boundaries
Tokens feel like pure presentation, so teams happily pipe tenant config straight into ConfigProvider. That's a mistake once any token value comes from user-influenced data — a tenant admin's brand-color picker, a white-label API response, or a URL parameter. The token object is a large, loosely-typed surface; injecting unvalidated keys means arbitrary downstream style behavior and, at minimum, a broken UI that's hard to attribute.
The boundary: validate tenant-supplied theming against an allowlist before it reaches the provider.
const ALLOWED_TENANT_TOKENS = ['colorPrimary', 'colorLink'] as const;
const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
function sanitizeTenantTokens(raw: Record<string, unknown>) {
const out: Record<string, string> = {};
for (const key of ALLOWED_TENANT_TOKENS) {
const value = raw[key];
if (typeof value === 'string' && HEX_COLOR.test(value)) {
out[key] = value;
}
}
return out; // unknown keys and malformed values are dropped
}Merge the sanitized result over your base tokens, never the other way around, so a tenant can't override structural tokens like font size or control heights.
Operational checks
- Version check: confirm the installed antd version in
package.jsonand thatConfigProvider'sthemeprop and the algorithm exports exist in that version's typings. The API is stable in 5.x but token keys evolve between minors. - Snapshot per algorithm: render a representative page (Button, Table, Form, Modal) under both
defaultAlgorithmanddarkAlgorithmin a test and snapshot the generated class output or computed styles. This catches token regressions when you bump antd. - Migration residue check: after migrating from v4, grep for leftover less-variable overrides (
@primary-color,modifyVarsin the bundler config) and global CSS resets targeting antd class names. These don't error — they just partially fight the token system, producing a UI that's 90% themed. - SSR check: with a persisted dark preference, load a page and confirm no theme flash and no hydration warning in the console. The theme decision must be identical on server and client — read the preference from a cookie, not
localStorage, in SSR setups, since the server can't seelocalStorage. - Boundary check: feed a mocked tenant config with an invalid color (
"red; }"or a non-string) through the sanitizer and confirm it's rejected before reaching the provider.
Failure modes
- Nested providers with conflicting tokens. A subtree wrapped in a second
ConfigProvidersilently uses different tokens. Symptom: the same Button looks different on two pages. Fix: lift tokens to the root; nest only when a subtree genuinely needs a distinct theme. - SSR hydration mismatch. Server renders light, client renders dark because the preference source differs. Symptom: React hydration warnings and a flash. Fix: single source of truth readable on both sides (cookie or inline script that sets a class before hydration).
- Stale generated styles. In some older cssinjs versions, toggling themes without remounting could leave stale cached styles. If you see mixed light/dark components after a toggle, check your antd version and upgrade.
- Half-migrated v4 theming. less overrides plus tokens produce a partially themed UI with no error. Fix: migrate fully; remove
modifyVarsand antd-specific global CSS.
When this design changes
The single-root-provider design stops fitting when:
- Genuinely different themes coexist — e.g., a themed embed rendered inside the host app. Then a second provider is correct, but isolate it deliberately and document the boundary.
- Style-generation cost shows up in profiles. Runtime CSS-in-JS adds generation work; extremely render-heavy pages with many nested providers should be measured. Usually the fix is flattening providers, not abandoning tokens.
- You need build-time-static styles for a constrained environment (some micro-frontend shells, strict CSP without
styleallowances). That pushes you toward extracting static CSS or reconsidering antd v5 entirely.
For the common case, though: one root provider, algorithm-driven dark mode, preference read before first paint, and a validation layer on anything tenant-supplied. That covers the requirements with the least surface area to maintain.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.