Theming

One file holds the values. A provider decides which of its modes is active. Ant Design components and Tailwind utilities read the result together.

How it works

Three hops, and nothing in the chain is hand-maintained.

A theme file of seeds and deviations compiles into a typed Ant Design ThemeConfig. The provider hands that config to antd, which emits every token as a CSS variable. antuikit.css maps those variables onto Tailwind theme keys, so a utility class and an antd component resolve to the same value.

tokens/acme.tokens.json the values you write
| tokens:build
v
lib/acme.theme.generated.ts a typed antd ThemeConfig
| <AntuikitProvider theme={...}>
v
--ant-color-primary antd's own CSS variables
| @theme inline
v
bg-primary / text-primary Tailwind utilities

The names at every hop are Ant Design's own token names, and each Tailwind theme variable maps to one of them, so there is no second vocabulary to keep in sync.

The theme file

One theme is one file, and light and dark are modes inside it.

colorPrimary is the same in light and dark, so it is written once and cannot drift between two files. Multiple themes are still multiple files, one compiled module each.

{
"$schema": "../schema/tokens.schema.json",
"name": "acme",
"seed": {
"colorPrimary": "#006bff",
"borderRadius": 8,
"controlHeight": 32,
"fontWeightStrong": 500
},
"palettes": {
"emerald": "#00c583",
"violet": "#7f22fe"
},
"modes": {
"light": {
"alias": { "colorBgLayout": "#f5f6f8" }
},
"dark": {
"alias": { "colorBgLayout": "#16171b" }
}
},
"components": {
"Button": { "paddingInline": 20, "primaryShadow": "none" }
}
}

Run tokens:build after editing it. It compiles the file into a typed module and checks every key against the installed Ant Design, so the valid keys follow antd upgrades.

A key antd does not publish is a warning, not a build failure. It still compiles into a typed, autocompleting token, so a designer can add a new variable in Figma without waiting on the kit.

The four levers

Which one to reach for, and which one wins when two apply.

seed
The inputs the algorithm derives from. One change moves a whole ramp, so a primary seed gives you every hover, active and border shade with it.
palettes
Extra color ramps beyond antd's own closed list of thirteen. One seed hex each, expanded to ten shades per mode.
modes.<mode>.alias
Per-mode deviations, for values you want to state rather than derive. A layout background, a text color.
components.<Name>
Per-component antd token overrides, scoped to that component and nothing else.

An alias deviation beats the algorithm's output for that key, and only that key. Seeds and aliases are resolved in one pass, so a stated colorBgLayout comes back unchanged while colorPrimaryHover is still derived from the seed.

Intent, not results

The file stores what you decided, never what the algorithm computed.

Ant Design derives more than five hundred tokens from a few dozen seeds. A theme file holds the seeds plus the deviations you chose, and nothing else. A file of resolved values would be a snapshot that goes stale when antd's algorithm improves.

Because the file stores only your decisions, an Ant Design upgrade carries your theme forward.

The provider

Three independent axes, and the theme they paint with.

<AntuikitProvider> renders antd's <App> as its one element. That element carries the CSS variables antd generates, so every descendant inherits --ant-* from it, and App.useApp() gives you themed message, notification and modal.

import { AntuikitProvider } from "@/components/antuikit-provider";
import { acmeTheme } from "@/lib/acme.theme.generated";
<AntuikitProvider theme={acmeTheme} defaultMode="auto" defaultCompact={false}>
<div className="min-h-screen bg-layout">
<App />
</div>
</AntuikitProvider>;

All three axes are readable and settable from anywhere below it.

const {
mode,
setMode,
isDark,
isCompact,
setCompact,
toggleCompact,
direction,
setDirection,
} = useAntuikitTheme();

Light, dark and auto

auto follows the operating system, so the setting and what is on screen are separate values.

Under auto the provider follows the operating system's prefers-color-scheme and keeps following it while the page is open. mode stays "auto" however that resolves, and isDark reports what it resolved to.

Render a theme picker from mode, because it is what the reader chose. Branch every visual decision on isDark, because it is what is on screen.

import { useAntuikitTheme } from "@/components/antuikit-provider";
import { Segmented } from "@/components/ui/segmented";
export function ModePicker() {
const { mode, setMode, isDark } = useAntuikitTheme();
return (
<div className="flex items-center gap-sm">
<Segmented
value={mode}
onChange={setMode}
options={["light", "dark", "auto"]}
/>
<span className="text-secondary text-sm">
currently {isDark ? "dark" : "light"}
</span>
</div>
);
}

The provider mirrors the resolved mode onto <html>: the light or dark class, and color-scheme. It always does this, with no prop to turn it off. It themes the page canvas, including the area behind overscroll, and it is what Tailwind's dark: variant reads.

First paint

The one place theming needs help from outside React.

Client-rendered. Under auto the system preference is read synchronously during the first render, so everything inside the provider is correct on frame one. The <html> element is not: the theme class and color-scheme land in an effect, one frame late, which a dark-mode visitor sees as a white flash. A blocking inline script in <head> that stamps both before first paint removes it, and hands over to the provider rather than competing with it.

<script>
(function () {
try {
var stored = localStorage.getItem("theme");
var dark =
stored === "dark" ||
(stored !== "light" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
var root = document.documentElement;
root.classList.add(dark ? "dark" : "light");
root.style.colorScheme = dark ? "dark" : "light";
} catch (e) {}
})();
</script>

Server-rendered. A server cannot know the system preference, so under auto it renders light and a dark visitor's first paint is wrong until hydration. The script above fixes only the part that does not depend on antd's variable block, which is the canvas behind overscroll. The complete fix is to stop asking auto to guess: persist the choice in a cookie and pass it as defaultMode, and the server serializes the right variable block in the first response.

Next.js needs two more things, both of them documented on the Get Started page: suppressHydrationWarning on <html>, because the script mutates the element React hydrates, and <AntdRegistry>, so antd's styles are in that same first response with the layer wrapper intact.

Compact

A second axis that composes on top of whichever mode is active.

Compact changes sizing only: type sizes, line heights, control heights and part of antd's own size ramp. It touches no color and no radius. Your utilities read the same variables, so they go compact alongside antd's components, and a layout built from text-*, h-control-* and the token-bound padding steps tightens as one.

The padding steps that follow compact are p-xs, p-sm, p-base, p-md and p-lg. The outer ones, p-xxs, p-xl and p-xxl, stay the same, as they do in antd.

const { isCompact, toggleCompact } = useAntuikitTheme();
<Switch checked={isCompact} onChange={toggleCompact} />;

Direction

One setting that has to reach antd and the page at the same time.

antd's ConfigProvider takes a direction and that is all antd needs: it propagates through context and antd's components add their own right-to-left classes. It writes no dir attribute anywhere, though, and two things outside antd read the DOM instead: Tailwind's rtl: and ltr: variants, whose selectors key on the attribute, and the browser itself for bidi reordering, scrollbar placement and caret behavior.

So one setting has to reach ConfigProvider and <html dir> at the same time, and the provider is the single place that does both.

It does not set antd's locale, and it does not flip icons you place yourself. Add rtl:-scale-x-100 to a directional icon.

Scoped theming

A dark panel inside a light app, without a second provider.

<ThemeScope> re-themes a subtree. It renders a <div> that carries the scope's variables, so antd components and your utilities re-scope together. It writes nothing to <html>, and it nests.

It follows the root's mode. To keep a region in one mode, pass the same config for light and dark.

import { ThemeScope } from "@/components/theme-scope";
<ThemeScope theme={marketingTheme}>
<aside className="bg-container text-text p-lg rounded-sm">
<Button type="primary">Upgrade</Button>
</aside>
</ThemeScope>;

Component tokens

Change one component without touching the rest.

Ant Design exposes a token set per component. Declare them in the theme file so the change travels with the theme, rather than in a provider prop at one call site.

"components": {
"Button": {
"paddingInline": 20,
"fontWeight": 500,
"primaryShadow": "none"
},
"Table": {
"headerBg": "transparent",
"rowHoverBg": "#f5f6f8"
}
}

A per-component token only exists once that component mounts, so it is not a value other parts of the system can read. Reach for a seed or an alias when more than one component should follow the change.

Tailwind utilities

Every themed value has a utility class, and there are two traps to know.

antuikit.css is one @theme inline block that maps antd's variables onto Tailwind theme keys. Keep inline if you edit it. A plain @theme resolves the variables at the root, and a ThemeScope inside would be ignored.

<div className="bg-container border border-border rounded-sm p-md">
<h3 className="text-text font-medium">Invoice</h3>
<p className="text-secondary">Issued on 3 March</p>
<span className="text-description text-sm">Reference AC-1182</span>
</div>

Two traps to know before you write a class name:

  • Style the element antd paints, not the variable antd reads. antd injects its per-component variable blocks unlayered, and an unlayered author style outranks every cascade layer. So setting --ant-segmented-item-selected-bg on the component root does nothing, while a class that paints the element itself wins. When an override does not apply, check this first.
  • Check which element carries the variable before concluding it is reachable. antd sometimes defines a group-level variable on each child rather than on the group, and the child is also the element that reads it. Setting it on the parent then does nothing, inline styles included.

The Tokens explorer lists every token by name, with its value in each mode.

AntuikitProvider
The provider's props. All three axes are uncontrolled: these set the starting value, and useAntuikitTheme moves them afterwards.
useAntuikitTheme
Read or set all three axes from anywhere below the provider. mode is the setting, and isDark is what is on screen.
Theme file
The shape of a tokens file. Everything except the name is optional, and Ant Design's algorithm derives what you leave out.