Theming your app
Restyle the whole system from one override sheet, and keep it honest as the registry moves.
Every colour, radius and shadow a component paints comes from a token in theme.css, so
restyling the system is a file of your own that redeclares the tokens you want to move. Components are
untouched — which is what lets you take the next registry update.
The shape of it
@redpanda/theme installs an example sheet next to the theme. Copy it out first: everything under
redpanda-ui/ is ours and gets replaced by the next shadcn add, and this file is yours.
cp components/redpanda-ui/style/theme-overrides.example.css src/theme-overrides.cssImport it after the theme — @theme blocks merge last-wins, and a [data-theme='dark'] block
outranks the registry's by source order:
@import "tailwindcss";
@import "tw-animate-css";
@import "./components/redpanda-ui/style/theme.css";
@import "./theme-overrides.css";A sheet has two halves, and they are not symmetrical:
@theme {
/* The one shape knob — every rounded-* rung derives from it. Not per-theme. */
--radius: 0.25rem;
--color-primary: #c93617;
--color-primary-hover: #b1300f;
--color-primary-pressed: #9c2a0d;
--color-primary-foreground: #fff4f0;
--color-primary-wash: rgba(201, 54, 23, 0.07);
--color-primary-wash-pressed: rgba(201, 54, 23, 0.12);
}
/* Add `.dark` beside this selector if something in your app sets the class instead — the shipped
theme answers to both, and a sheet that answers to one of them goes dark-blind in the other. */
[data-theme='dark'] {
--color-primary: #ff7a52;
--color-primary-hover: #ff9575;
--color-primary-pressed: #ffb096;
--color-primary-foreground: #101010;
--color-primary-wash: rgba(255, 122, 82, 0.09);
--color-primary-wash-pressed: rgba(255, 122, 82, 0.15);
}You need no ramp behind those values and there is none to reach for: each token holds its own literal,
so theme.css is the complete list of colours the system has. Pick roles with the three questions on
the Theme page — what is it made of, how does it react, what sits
on it.
Four rules, and why each one is invisible without a checker
A colour token that names nothing compiles to nothing. There is no error and no warning: the class still exists, it just paints our value where you meant yours. That is the failure mode every rule here guards, which is why the theme ships a checker rather than a style guide.
- Name only tokens the theme declares. A role renamed or dropped in a registry update leaves your sheet quietly ineffective for that role.
- A family you override the rest of, you override the states of. Half a family is the worst case,
not a partial one: the control takes your colour at rest and ours under the pointer — the one state
no screenshot at rest can show. That includes the
-washresting beside a tone, andsurface-recess, which issurface-subtleat half alpha rather than a role of its own. - Override both themes, or neither. The registry's dark block still applies to every rung you leave alone, so a half-converted sheet puts our indigo under the pointer inside your palette.
static-*has no dark value, by definition — it is the ground that stays dark in both themes. Declare it in the light block only, and always with its-foreground: the ink does not flip either, so moving the ground alone is how light ink ends up on a light fill in exactly one theme.
Wire the checker into your build
{
"scripts": {
"theme:check": "bun components/redpanda-ui/style/theme-check/cli.mts --overrides src/theme-overrides.css",
"build": "npm run theme:check && next build"
}
}src/theme-overrides.css ok — 281 semantic overrides across 145 roles, 15 registry roles
deliberately inherited, no dead names.It reports six things, each needing your sheet and theme.css read together: a dead name the theme
does not declare, a duplicate that makes an earlier line unreachable, an unresolved var()
chain, a flipped static-* role in the dark block, an incomplete static ground with no ink, and
a hole — half a family.
| flag | default |
|---|---|
--overrides | required — the sheet to check |
--theme | ../theme.css beside the script, which is where @redpanda/theme installs it |
--palette | comma-separated scale prefixes your sheet owns outright (ink,ember), exempt from rule 1 |
--palette is for a sheet that keeps its own ramps: define --color-ink-* and point the semantic tokens
at them with var() if that is how your design system is organised. The registry has no ramps of its
own, deliberately — a rung a call site pins itself to is a rung that can never move — but your sheet is
free to.
It is the same code the registry runs on its own consumers: the rule that decides what counts as half a
family is one module (theme-check/rule.mts), called by this CLI, by the checker on Lookout's sheet, and
by the test behind the playground's built-in themes. A consumer's checker and ours cannot disagree.
Runs under bun, or node 24+, which strips the TypeScript itself.
Try it before you commit to it
The playground's theme picker holds four example palettes — Nova, Ransom, Squamish, Void — and a Customise… dialog over every token in the theme. Edit a value and it applies live across every component while you browse; the CSS override tab is the same sheet as above, ready to paste into yours, and the Paste button reads one back in. The four palettes exist to prove a component reads from tokens rather than from our defaults — they are examples, not products.
Switching light and dark
Nothing in your app should write data-theme, the .dark class, or color-scheme by hand.
ThemeProvider writes all three, and ThemeScript / initTheme do it before the first paint — see
step 5 of the install guide.
When a token you want is missing
Add it to the registry rather than improvising: an opacity shortcut (bg-primary/10) composites against
whatever happens to be behind it and cannot be retuned centrally, and a border borrowed from another
family drifts the moment that family moves. Both are refused by tokens:check. Tailwind's own palette
stays enabled as the escape hatch, and reads in a diff as exactly what it is — outside the design system,
with no per-theme value, no hover rung and no asserted contrast.