Documentation

Theming

A project's colors and corner radius come from a theme: a preset baked into the compiler, and an optional stylesheet of CSS custom properties.

Tokens resolve in color classes — bg-card, text-muted-foreground, border-border — so a project gets a semantic vocabulary instead of hex literals scattered through its markup.

The two layers

The second is cascaded over the first:

LayerWhereUse it for
Presetpreset = "shadcn" in whitehall.tomlThe baseline look
The theme stylesheettheme.css beside whitehall.toml, or [theme] fileThe project's theme — the shareable artifact

theme.css is discovered, not registered — the way Vite finds vite.config.js. If the file is not there, the preset stands on its own.


Presets

# whitehall.toml
[theme]
preset = "shadcn"
base_color = "olive"    # optional; "neutral" if you say nothing
PresetContents
shadcnThe 19 core shadcn tokens, light and dark, plus the styled components
noneEmpty palette. Color classes fall through to the Tailwind palette and Material roles, and components stay Material3, exactly as they did before themes existed. Current default.

The default is none while the styled component layer is being built out. The preset is what switches a project's components from Material3 to shadcn — so far <Button>, with the rest tracked in Components. Turning it on today repaints every color class and reshapes the components that have been ported, while the ones that have not are still Material. The default becomes shadcn when that mix stops being a mix.

The 19 tokens the shadcn preset defines:

background          foreground
card                card-foreground
popover             popover-foreground
primary             primary-foreground
secondary           secondary-foreground
muted               muted-foreground
accent              accent-foreground
destructive         destructive-foreground
border              input                 ring

Base colors

The preset builds its palette on a base color — the neutral ramp every token except destructive is derived from. This is the same knob as tailwind.baseColor in shadcn's components.json: it swaps the neutral underneath the whole palette without changing any other decision. Say nothing and the app is gray; say olive and it is green.

base_colorCast
neutralPure gray. The default, and shadcn's.
stoneWarm gray, faintly brown
zincCool gray, faintly blue
mauveGray with a pink cast
mistGray with a blue-green cast
oliveGray with a green cast
taupeGray with an orange-brown cast

These are shadcn's own seven, with the values taken verbatim from ui.shadcn.com/r/colors/<name>.json. The registry also still serves gray and slate, which predate Tailwind v4 and are not carried here — a project that wants either can write its ramp into the theme stylesheet.

A base color is a starting point, not a commitment: the theme stylesheet still overrides it token by token, and a [data-theme="olive"] block applies to olive alone.

Switching base colors at runtime

base_color picks one ramp at build time, which is what an app with a look wants. An app that lets its user pick binds it on <App> instead, the same way theme= is bound:

// src/main.wh
import $lib.stores.ThemeStore

<App theme={ThemeStore.mode} baseColor={ThemeStore.base}>
  <slot />
</App>
// src/stores/stores.wh
@store
object ThemeStore {
    var mode: String = $state("dark")
    var base: String = $state("neutral")
}

Writing ThemeStore.base = "olive" repaints the whole app, with no rebuild. Every ramp is emitted into WhitehallTheme.kt and chosen inside the composition, exactly as the two schemes already are.

Only the bound form is accepted. baseColor="olive" as a literal is the same decision base_color in whitehall.toml already makes, and one setting with two spellings is worse than one. A name the store holds that is not a real base color paints the project's own rather than failing — it arrives at runtime, where there is nothing better to do. The cost is size: seven ramps is 266 extra color literals, so they are emitted only when baseColor= is bound. An app that never mentions it carries one palette, exactly as before.

The theme stylesheet

Tokens are CSS custom properties, in shadcn's own format — so a theme from the shadcn registry, or a web project's app.css, is used as it is:

/* theme.css, beside whitehall.toml */
:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  --primary: oklch(0.205 0 0);
  --ring: #a3a3a3;
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  --primary: oklch(0.922 0 0);
}

theme.css beside whitehall.toml is found without being named. To use another file — a project that points at its web half's stylesheet, so the pair cannot drift — name it:

# whitehall.toml
[theme]
file = "web/src/app.css"

It is read the way a browser reads it, for <html class="dark" data-theme="…">:

SelectorApplies to
:rootboth schemes
.darkthe dark scheme
[data-theme="stone"]the stone base color, both schemes
.dark[data-theme="stone"] (either order)the stone base color, dark

Later rules win over earlier ones, and a compound selector wins over a simple one, exactly as in CSS — so a token set in :root reaches the dark scheme unless .dark sets it too. Anything that is not one of those selectors (@import, @theme, @layer, @font-face, rules for other elements) and any property that is not a color (--font-sans, calc(…), var(…)) is not a token and is skipped. A value that is plainly a color and does not parse is a build error naming the file and line. A token the stylesheet does not set keeps its preset value.

To find where a token came from: whitehall why src/routes/+screen.wh#bg-primary names the stylesheet line that set primary in each scheme, or says the preset did. whitehall why theme.css:12 names the tokens line 12 sets.

The radius scale

--radius is one length and every corner in the app is derived from it, so moving it reshapes the whole app at once — which is the point of having it. rem is 16px, and a px is a dp. shadcn's own multipliers:

ClassAt the default 0.625rem
rounded-smradius × 0.66dp
rounded-mdradius × 0.88dp
rounded-lgradius10dp
rounded-xlradius × 1.414dp
rounded-2xlradius × 1.818dp
rounded-3xlradius × 2.222dp
rounded-4xlradius × 2.626dp

rounded-none and the bare rounded are Tailwind's own and stay fixed, as does everything under preset = "none".

Font

# whitehall.toml
[theme]
font = "geist"              # geist | system | "static/Inter.ttf"
ValueEffect
geistshadcn's own typeface, bundled with the compiler. The shadcn preset's default.
systemThe platform font (Roboto on Android). Adds nothing to the APK. The none preset's default.
a pathA font file in the project, resolved from the project root.

Geist ships inside the compiler (169 KB variable TTF, SIL OFL 1.1) and is written to res/font/ at build time. It is bundled rather than fetched from Google Fonts on purpose: a downloadable font needs the network on first run and flashes a fallback before it arrives. A project that would rather trade a flash for 169 KB can set font = "system". Weights are declared against the font's wght axis (400/500/600/700), so font-bold gets Geist's real bold rather than a synthesized smear of the Regular master. This needs API 26, which is min_sdk.

Color values

FormExample
Hex#0a0a0a, #fff
oklchoklch(0.145 0 0), oklch(62.8% 0.258 29.23), oklch(1 0 0 / 10%)

oklch is what shadcn and Tailwind v4 publish, so a theme copied from the shadcn registry pastes in unedited. Everything normalizes to #rrggbb, or #rrggbb/NN when translucent, before codegen.


Using tokens

Token names are used verbatim after bg-, text- and border-:

<Column class="bg-card border border-border rounded-lg p-16">
  <Text class="text-card-foreground text-lg font-bold">Total revenue</Text>
  <Text class="text-muted-foreground text-sm">+20.1% from last month</Text>
</Column>

Names may contain hyphens, which is what lets them mirror shadcn's vocabulary. Tokens keep working with an opacity suffix — bg-primary/50 is the lookup plus .copy(alpha = 0.5f).


Dark mode

Which scheme the app starts in is [theme] mode in whitehall.toml — "light", "dark", or "auto" (the default, following the device):

[theme]
mode = "dark"

<App theme=…> in src/main.wh overrides it, and is the one way to bind it to a store:

<App theme={Settings.mode}>
  <slot />
</App>
themeMaterial ColorSchemeTheme tokens
"dark"darkColorScheme():root, then .dark
"light"lightColorScheme():root
"auto" (default)follows the OSfollows the OS
{Store.property}follows the storefollows the store

"system" is accepted as a synonym for "auto", since that is what SvelteKit's mode-watcher and next-themes call it.

All four work at runtime. Theme tokens resolve inside the composition, so "auto" follows the device and a store binding follows the store, repainting live in both cases — no rebuild. Material's own ColorScheme follows the same decision.

Material components

A palette that defines primary also fills Material 3's ColorScheme. Inside WhitehallTheme, a MaterialTheme takes each role from the token that plays the same part, so a Scaffold, a ModalBottomSheet, a Snackbar or a spinner wears the project's colors rather than Material's baseline lavender:

Material roleToken
primary / onPrimaryprimary / primary-foreground
secondary, secondaryContainer / on…secondary / secondary-foreground
tertiary, tertiaryContainer, primaryContainer / on…accent / accent-foreground
background, surface, surfaceBright, surfaceContainerLowestbackground
onBackground, onSurfaceforeground
surfaceVariant, surfaceDim, surfaceContainerHighest / onSurfaceVariantmuted / muted-foreground
surfaceContainerLow, surfaceContainercard
surfaceContainerHighpopover
inverseSurface / inverseOnSurface / inversePrimaryforeground / background / primary-foreground
error / onErrordestructive / destructive-foreground
outline / outlineVariantinput / border

A role whose token the palette lacks keeps Material's value. surfaceTint is transparent, because a flat palette has nothing to tint raised surfaces toward.


The runtime token layer

Every build writes a WhitehallTheme.kt into the project package, holding the resolved palette as Compose Unstyled theme tokens:

object WhitehallTokens {
    val colors = ThemeProperty<Color>("colors")

    val background = ThemeToken<Color>("background")
    val mutedForeground = ThemeToken<Color>("muted-foreground")
    // ...
}

Both schemes are emitted, and which one is in force is decided inside the composition — so a theme= bound to a store, or left on "auto", can change without a rebuild. Names are converted to Kotlin: muted-foreground becomes mutedForeground. Two tokens that would produce the same Kotlin name are a build error rather than a silent shadow.

This layer sets a floor of Kotlin 2.4 — composeunstyled-theming is published against it. A project pinning something older is refused at load time.

Current limits

  • The launch window follows the theme, but has no launch screen. The window Android draws before the app's first frame takes its color from background, per scheme. Drawing your own launch screen over it is designed but not built.
  • Widgets and notifications have no theme. Glance runs in a different composition and cannot read the app's CompositionLocals, so src/widgets/ keeps the Tailwind palette and hex. This is a property of Glance, not a gap to be closed.
  • Wear bakes its tokens. The Wear module has neither WhitehallTheme.kt nor the theming dependency, and draws with androidx.wear.compose, so a token in src/wear/ is the literal hex for the scheme the build picked. Pin theme= for a Wear app that uses tokens.
  • Most components are still Material3. <Button> is shadcn's; the rest are being ported one at a time.
  • A token named after a Material role shadows it. With a populated palette, bg-primary resolves to the project's primary rather than MaterialTheme.colorScheme.primary. That is the intent, but it means turning on a preset changes existing color classes.
  • The single-file path has no theme. whitehall compile foo.wh does not read whitehall.toml, so color classes naming theme tokens are unknown-class errors there.

See Also