Scoped Dark Mode in Ant Design v5: Nested ConfigProvider Without a Second Build
Ant Design v5 turned scoped theming from a Less-compile problem into a runtime decision. How nested ConfigProvider works, what it costs, and how to verify it.
10 Oct 2026, 20:59 UTC

Every dashboard eventually grows the same requirement: one region of the page — a sidebar, a log viewer, a monitoring panel — needs to render dark while the rest of the app stays light. With Ant Design v4 that was a build problem. Less variables were compiled into stylesheets ahead of time, so a dark region meant shipping a second compiled theme and managing specificity between them, or hand-writing overrides for every component inside it.
Ant Design v5 changed the unit of theming from build-time variables to runtime tokens, and that turns scoped theming into a one-wrapper decision. This post walks through the nested ConfigProvider pattern, the merge rules that matter once you start nesting, and the runtime costs you accept in exchange.
What changed under the hood
v5 replaced its Less pipeline with a CSS-in-JS runtime (@ant-design/cssinjs). Design tokens are organized in layers: a handful of seed values (like colorPrimary), a derived map of hover, active, and background variants computed from those seeds, and alias tokens that components actually consume (colorText, colorBgContainer, spacing, radii). Algorithms — theme.defaultAlgorithm, theme.darkAlgorithm, theme.compactAlgorithm — are functions that turn seeds into full token maps.
The practical consequence: a theme is now data, not a stylesheet. Two themes can coexist on one page, and any subtree can opt into a different one at render time.
| v4 (Less) | v5 (tokens) | |
|---|---|---|
| Theme lives in | Compiled .less variables | JS token objects |
| Two themes on one page | Second stylesheet plus specificity care | Nested ConfigProvider |
| Runtime switching | Preload both themes or recompile | Update the theme prop |
| Custom component colors | Hardcoded hex or Less-variable workarounds | theme.useToken() |
Assumption for everything below: antd 5.x on React 18. Pin the exact version in your lockfile — alias token names occasionally shift between v5 minors, so test theme-related upgrades in staging before promoting them.
Worked example: one dark panel in a light page
Run this in any React 18 project with antd installed (npm install antd). Render <BillingPage /> standalone or inside your app's root provider.
import { Button, Card, ConfigProvider, Space, theme } from 'antd';
function StatusPill({ label }: { label: string }) {
const { token } = theme.useToken();
return (
<span
style={{
padding: `${token.paddingXXS}px ${token.paddingSM}px`,
borderRadius: token.borderRadiusLG,
background: token.colorFillSecondary,
color: token.colorText,
fontSize: token.fontSizeSM,
}}
>
{label}
</span>
);
}
export default function BillingPage() {
return (
<Space direction="vertical" size="large">
<Card title="Payment methods" style={{ width: 420 }}>
<Space>
<StatusPill label="3 active" />
<Button type="primary">Add card</Button>
</Space>
</Card>
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm }}>
<Card title="Nightly reconciliation" style={{ width: 420 }}>
<Space>
<StatusPill label="Running" />
<Button type="primary">Open log</Button>
</Space>
</Card>
</ConfigProvider>
</Space>
);
}What you should see: the second Card renders with a dark surface and light text; the first stays light. The interesting part is StatusPill — it is rendered twice with no props changed, but picks up different colors in each scope, because theme.useToken() returns the tokens of the nearest ConfigProvider. That is the mechanism that lets your own components participate in scoped theming instead of hardcoding hex values.
To verify: open DevTools, inspect the dark card's container element, and confirm its background comes from a generated class rather than an inline override. You will also see the <style> tags the CSS-in-JS runtime appends to <head>. If your root provider sets a custom colorPrimary, check that the dark panel's primary button inherits it — global token overrides merge across scopes.
Algorithms can be combined with array syntax when you need density plus darkness: algorithm: [theme.compactAlgorithm, theme.darkAlgorithm] is a common pairing for dark data tables.
Merge rules to know before you nest everywhere
- Global tokens merge deeply. An inner scope inherits the outer
tokenoverrides unless it redefines them. - Algorithms replace rather than stack. Setting
algorithmin an inner provider overrides the outer one; pass an array to combine behaviors. - Component-level overrides deserve a test. How
componentstoken overrides interact across nested providers has been a source of surprises — verify on your pinned version whether inner component tokens merge with or replace outer ones before building a hierarchy that depends on it. (Flagged for review.) - Custom tokens need type augmentation. If you add nonstandard keys under
theme.token, declare them via module augmentation in a global.d.tsor TypeScript will reject them.
The trade-off: theming now runs in the browser
Runtime theming is not free, and it is worth naming the three costs.
Bundle. The CSS-in-JS runtime adds weight on the order of 15 KB gzipped, and the token system ships as a whole — you cannot tree-shake individual tokens. Measure your own build with your bundler's analyzer rather than trusting any single published number.
Recomputation. Each component's styles are calculated from the current tokens on first use, and mutating tokens at high frequency — a live color-picker preview, for instance — triggers recalculation and re-injection. If you need that pattern, look at cssVar mode, which exposes tokens as CSS custom properties so the browser handles changes. It does not cover every token type (gradients and complex shadows are the usual gaps), and it landed partway through the v5 cycle, so confirm it exists in your installed minor before relying on it. (Exact introducing version: verify against the changelog.)
Server rendering. Styles are injected client-side by default, so server-rendered pages can flash before styles arrive. Next.js App Router apps handle this with a client wrapper around ConfigProvider that flushes collected styles via useServerInsertedHTML; other server setups need an equivalent style-extraction step. If you server-render, budget time for this — it is the least optional of the three costs.
Start small, verify, pin
- Wrap exactly one panel in a nested
ConfigProviderwiththeme.darkAlgorithmand confirm both scopes render correctly. - Check
theme.useToken()output in one custom component inside the dark scope. - Build for production and confirm the cssinjs runtime appears where you expect in your bundle analysis.
- If you server-render, verify first paint shows no flash before shipping.
- Pin
antdto an exact version and re-run steps 1–2 after any upgrade.
The nested-provider pattern is the rare migration payoff that is immediately usable: no build changes, no second stylesheet, one wrapper component. The discipline is in the verification — token behavior across nested scopes is exactly the kind of thing that can shift quietly between minor releases.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.