---
title: Theme
description: Customize the look and feel of your application with the Redpanda UI theme system.
icon: palette
author:
  name: malinskibeniamin
  url: https://github.com/malinskibeniamin
search:
  keywords: [tokens, colors, dark mode, css variables]
  boost: 2
related:
  - /theming
  - /redpanda-provider
---

```package-install
npx shadcn@latest add @redpanda/theme
```

The 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](/docs/theming) — 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](/docs/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.

<ComponentPreview name="theme-family-map-demo" />

### Text

Text color tokens for content, semantic states (success, warning, error, informative), actions, and inverse text for use on dark surfaces.

<ComponentPreview name="theme-text-demo" />

### 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.

<ComponentPreview name="theme-semantic-pairs-demo" />

### 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.

:::warning
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](/docs/interaction-states) for how, and for which archetype to reach for.

<ComponentPreview name="theme-background-demo" />

### Tone borders

One border per tone. Only the tones whose borders a control actually moves carry hover and pressed steps.

<ComponentPreview name="theme-tone-borders-demo" />

### 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.

<ComponentPreview name="theme-borders-demo" />

### Sidebar

Tokens used by the sidebar component — the main surface, primary action, and accent, each paired with its foreground color.

<ComponentPreview name="theme-sidebar-demo" />

### 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`.

:::info
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.
:::

<ComponentPreview name="theme-static-demo" />

#### 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:

```tsx
<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.

:::warning
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:

```tsx
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.

<ComponentPreview name="theme-chart-demo" />

## 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](/docs/interaction-states).    |
| What sits on it?    | The fill's `-foreground`.                                                                  |

:::info
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](/docs/typography) page for the full scale.

<ComponentPreview name="theme-typography-demo" />

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](#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:

```tsx
<InputGroup className="[--radius:9999px]" />   // a pill
<Card className="[--radius:0]" />              // square corners
```

At the default `0.5rem` each rung is exactly the value Tailwind ships, so the knob changes nothing until you turn it.

<ComponentPreview name="theme-shape-demo" />

### Elevation

Shadow utilities for expressing depth and visual hierarchy, from subtle card shadows to floating modals.

<ComponentPreview name="theme-elevation-demo" />
