Theme
Customize the look and feel of your application with the Redpanda UI theme system.
Made by malinskibeniaminThe theme is one CSS file of design tokens. Every colour, type style, radius and shadow a component uses comes from it, so restyling the system means changing values in one place rather than hunting through components.
Values are plain hex and rgba() literals — readable and diffable. OKLCH appears only in the generator that derives the interaction states, never in the file you edit.
This page is the token surface. To put your own palette on it, see Theming your app — the sheet is yours, this file stays ours, and a checker holds the two together. Moving an app off an earlier theme is a rename job: the changelog carries the table.
Colors
Semantic tokens, and nothing else. There is no numbered ramp to reach into — each token holds its own value, so this page is the complete list of colours the system has.
Click any class name or swatch to copy it.
How to pick a colour
Five roles: <tone> is the ink, <tone>-wash the wash, <tone>-line the border, <tone>-strong a saturated indicator, and surface-<tone> a solid fill you put ink on. shadcn's core roles keep their own names.
Two words split the vocabulary and never overlap on one family: -wash is a tone's translucent fill, and subtle is the quiet rung of a neutral family — the ink (subtle), the ground (surface-subtle), the hairline (border-subtle).
A role only exists where something renders it, so not every tone carries all five. The four status tones (destructive, warning, success, informative) have the full set; primary, secondary and brand have no surface- fill or -strong indicator, since their bare name serves as both ink and fill. The gallery below is the authority — a role a tone does not declare compiles to nothing.
Text
Text color tokens for content, semantic states (success, warning, error, informative), actions, and inverse text for use on dark surfaces.
Semantic Pairs
Every base color with a matching *-foreground token, rendered as text on its companion background so the intended contrast is visible at a glance.
Background & Surface
There is one ground ladder, and every rung has a distinct dark value even where light mode puts two of them on the same grey:
| rung | light | dark | what sits there |
|---|---|---|---|
page | #ffffff | #060606 | the app shell — what body paints |
background | #ffffff | #161616 | the default panel — a card, a popover, a knob |
surface-recess | #dcdcde80 | #23232380 | the quietest recess — a table footer, a code block, a striped row |
surface-subtle | #dcdcde | #232323 | a quiet raised block inside a panel |
surface-strong | #c3c4c6 | #333333 | a strong raised block; put foreground on it, not subtle |
surface-recess is the one translucent rung, so it layers correctly inside a card or a dialog rather than needing a value per ground. It is surface-subtle at half alpha, which is why it sits quieter than it in the ladder. Reach for the token rather than the alpha: bg-surface-subtle/40 composites against whatever happens to be behind it and cannot be retuned centrally, so tokens:check refuses it.
card, popover and muted are shadcn's names for rungs on that ladder rather than values of their own — declared as var() references so the two vocabularies cannot drift apart, with a test asserting it. Same for the inks: card-foreground, popover-foreground and muted-foreground point at foreground and subtle.
The one place we diverge from shadcn's meaning: background is the default panel, not the page. In light mode page and panel are both white on purpose — light mode separates a card with a border and a shadow rather than with tone. Dark mode steps, which is why a card reads as lifted there.
surface-<tone> is that same fill role with a tone in place of the neutral, and it is the family that carries -hover and -pressed. A ground does not react. The ink on a solid tone fill is that tone's own -foreground — bg-surface-destructive text-destructive-foreground, not a global text-inverse — so the pair that ships is the pair theme-contrast.test.ts checks, and a consumer can retint one tone without moving the rest.
Never fill with an ink token. <tone> is the colour of text on the page —
bg-destructive is how a fill ends up a pale pink in dark mode, where the ink
has to lighten. Reach for surface-<tone> for a solid fill, <tone>-wash
for a wash, <tone>-strong for an indicator. bun run tokens:check fails on
the opacity shortcut (bg-primary/10) for the same reason: it composites
differently on every ground and cannot be retuned centrally.
Every one of those pairs clears WCAG AA in both themes, asserted in theme-contrast.test.ts, with one exception: light brand. That token is the red in the raw logo assets and a test holds it there, so it is the one colour a contrast result cannot move. At that red white on the fill reaches 4.21:1 and the ink on its own wash 3.57:1 — both short of AA 4.5. Clearing it needs a deeper brand red or dark ink on brand fills, so it is a brand decision rather than a bug; until it is made, both ratios are floored in the test so they cannot quietly worsen. Prefer primary where brand colour is not the point.
The -hover and -pressed values are generated from their rest token — see Interaction states for how, and for which archetype to reach for.
Tone borders
One border per tone. Only the tones whose borders a control actually moves carry hover and pressed steps.
Borders
border, border-subtle and border-strong are the neutral hairlines that separate content, dividers included; <tone>-line above is the coloured border of a control or a tinted card.
Every <tone>-line carries -hover and -pressed, and every light value clears 3:1 on the page. That floor is WCAG 1.4.11: on an outline button or an outline badge the line is the control's boundary, with no fill behind it to mark where the control ends, so a pastel hairline that reads nicely inside a tinted Alert is not enough on white. theme-contrast.test.ts holds them there.
border is the one neutral line that reacts — border-hover and border-pressed are what a draggable hairline steps through. border-subtle and border-strong are static, because nothing hovers a divider.
Sidebar
Tokens used by the sidebar component — the main surface, primary action, and accent, each paired with its foreground color.
Static
The one family that does not respond to the theme: a ground that stays dark in light and dark mode, plus the ink and wash that sit on it. Reach for it when the surface underneath is fixed — a Banner, a marketing band, content over an image.
Not to be confused with inverse-*, which means "inverted relative to the page" and so flips: white ink in light mode, near-black in dark. Right for a chip that inverts with the theme; over a ground that never moves it is white-on-white in one of the two.
The two halves are not mirror images. static-dark is a ground you place controls on, so it has no -hover — a ground does not react, the control on it does, and static-dark-accent / -accent-pressed is that reaction. static-light is a fill a control is made of, so it steps its own ramp with -hover and -pressed. If you find yourself wanting static-dark-hover, the control wants static-dark-accent.
An opacity ladder carries no role, so the theme names the shared steps
instead: modal-overlay for scrims, accent for the row hover tint,
<tone>-wash for a wash.
Pinning a subtree to one theme
static-* covers the ground and its own ink, not the components you put on it: a <Button variant="outline"> on a static-light card still takes secondary, a near-white in dark mode, and
lands at 1.23:1 on the white card.
Pin the subtree instead. theme.css declares the light palette on [data-theme='light'], .light as
well as :root, so a light island inside a dark app resolves every token to its light value. Set
colorScheme too, so the browser's own chrome — autofill, the caret, scrollbars — follows:
<div className="bg-static-light text-static-light-foreground" data-theme="light" style={{ colorScheme: 'light' }}>
<Button variant="outline">Continue with GitHub</Button>
</div>Reach for this on any surface that stays light whatever the app's theme — a login card, a marketing
band, a print view. data-theme="dark" pins the other way.
Two things do not follow the pin. Tailwind's dark: variant keys on any [data-theme='dark']
ancestor, so a dark: utility inside a light island still applies — registry components use tokens
rather than dark:, so this only bites your own code. And light values you override in your own
@theme block need [data-theme='light'], .light on that selector too, or an island falls back to
the registry's defaults.
Editors that cannot read a CSS variable
Monaco's defineTheme takes literal colours, so a hand-themed editor stops following the theme. lib/editor-theme resolves the tokens by painting a probe element and reading the computed colour back — whatever the value is, hex, a var() chain or an rgba() wash — and returns a plain object in Monaco's shape:
import { editorTheme } from '@/lib/editor-theme';
// in `beforeMount`, and again whenever the appearance changes
monaco.editor.defineTheme('redpanda', editorTheme({ transparentBackground: true }));
monaco.editor.setTheme('redpanda');base is chosen from the resolved background rather than passed in, so a caller cannot pair a light base with a dark ground. The result is a snapshot, not a binding: re-run it when data-theme flips.
Chart
Chart series colors (chart-1 through chart-5) for data visualization.
Building something new
Reach for Tailwind first. The theme adds a token or a utility only where Tailwind has no opinion — colour roles, the composite type styles, the two link utilities — so spacing, sizing, layout and layering are plain Tailwind, and z-50 means what it means everywhere else.
Picking colour for a new control is three questions:
| reach for | |
|---|---|
| What is it made of? | A fill → surface-<tone> or a bare role. An outline → <tone>-line. Text → the bare ink. |
| How does it react? | The matching -hover / -pressed. See Interaction states. |
| What sits on it? | The fill's -foreground. |
If a rung you expect is not there, that is a gap to fill deliberately, not a reason to
improvise: an opacity shortcut (bg-primary/10) and a border borrowed from another family
are the two failures tokens:check exists to catch. Add the family to
interaction-states/spec.mts, run bun run tokens:states, and the rung is derived from its
rest token with the rest of them.
There is a fourth question that never comes up: what about dark mode? Every token carries a value per theme, so the answer is always "nothing" — never write dark:. It hardcodes a two-theme world, and the registry ships more than two. A difference between themes is a token, not a second class; a test asserts the variant appears nowhere in component source.
All of it is enforced rather than reviewed: tokens:check gates the build on a class that cannot render, and the tests in src/scripts hold the contrast pairs, the focus ring, the type scale and the shape scale to what the theme declares.
When you really need a colour that isn't here
Tailwind's own palette is still enabled — bg-emerald-500, text-slate-400 — as the pressure valve, so the theme never leaves you stuck. It is also deliberately obvious: a Tailwind colour name in a diff says "outside the design system", which is the honest signal. It will not follow a theme, it has no per-theme value, no hover rung and no asserted contrast, and a consumer restyling the system will not reach it. Use it to unblock yourself, then add the semantic token.
Typography
The type scale ships as single text-* utilities — each bundles font size, line-height, weight and tracking, so one class is a complete style. See the Typography page for the full scale.
Composite styles never overload Tailwind's raw size scale (text-xs … text-5xl), so an existing text-sm is untouched. For a size with no weight or tracking, use that scale — extended down one step with text-2xs (10px) for dense tables and meta rows. For aligned columns of figures, compose tabular-nums onto any text style.
Shape & Elevation
Radius, width and shadow utilities. The colour of a border lives under Colors — <tone>-line for a control's edge, border-* for the neutral hairlines.
Radius & width
Tailwind's own rounded-* scale, with every rung derived from a single --radius. Set it and the whole shape language follows — on the page, or on any subtree:
<InputGroup className="[--radius:9999px]" /> // a pill
<Card className="[--radius:0]" /> // square cornersAt the default 0.5rem each rung is exactly the value Tailwind ships, so the knob changes nothing until you turn it.
Elevation
Shadow utilities for expressing depth and visual hierarchy, from subtle card shadows to floating modals.