Documentation

Theming

A project's colors, corner radius and typeface come from a theme — a preset baked into the compiler, an optional theme.toml, and an optional inline table.

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

The three layers

Each layer overrides the one before it:

LayerWhereUse it for
Presetpreset = "shadcn" in whitehall.tomlThe baseline look
theme.tomlNext to whitehall.toml, found automaticallyThe project's theme — the shareable artifact
[theme.colors]Inline in whitehall.tomlA one-off tweak not worth a file

theme.toml is discovered, not registered — the way SvelteKit finds svelte.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
shadcnshadcn's tokens, light and dark, over the full component library. Geist, 10dp radius. The default — say nothing about theming and this is what you get.
material3Material's own components — <Button> is androidx.compose.material3.Button, with its ripple and elevation. Roboto, 12dp radius, and color classes routed through MaterialTheme.colorScheme so dynamic color works.
noneThe same component library, unstyled. Every token defined in black, white and two greys, so components render and are legible with no design in them. Paint it yourself.
Classes do not change with the preset. Everything in the class reference works under all three — p-16, bg-red-500, blur-xl, and the semantic colors too: bg-background, text-foreground, text-muted-foreground, bg-card, border-border, text-destructive. Write text-foreground and you get the app's text color, whichever preset it is on.
material3 gives you Material's components, not shadcn's in Material colors. The six with a Material counterpart — <Button>, <Card>, <TextField>, <Switch>, <Checkbox>, <Icon> — compile to the real thing. The rest of the library is shadcn's and is not available: write your own src/components/Alert.wh if you want one.
Bringing your own. A component you write wins over the built-in of the same name, under every preset — import $app.components.Alert beside <Alert> and yours is the one that compiles. Writing the import is the statement of intent, the same way it is in Svelte. It is also how you fill a gap under material3, where the shadcn half of the library is not available.

Which one Material moves

base_color picks one of shadcn's neutral ramps and only shadcn reads it. Material generates its schemes from a seed hue instead, which is a different idea, so the setting is ignored rather than rejected there — switch presets to compare without having to delete the line. To move Material's color, override the role:

[theme]
preset = "material3"

[theme.colors]
primary = "#00639B"

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, taken verbatim from the registry. A base color is a starting point, not a commitment — everything in theme.toml and [theme.colors] still overrides it token by token.

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 $app.stores.ThemeStore

<App theme={ThemeStore.mode} baseColor={ThemeStore.base}>
  <slot />
</App>
// src/stores/stores.wh
@store
object ThemeStore {
    var mode: String = "dark"
    var base: String = "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 already makes, and one setting with two spellings is worse than one. The cost of binding it 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.

theme.toml

Every field is optional; anything absent keeps its preset value.

# Which of shadcn's seven neutral ramps the palette is built on.
base_color = "neutral"

# Base corner radius, which drives the whole rounded-* scale.
# Accepts "0.625rem", "10px", "10dp", or a bare number in dp.
radius = "0.625rem"

# Tokens here apply to both schemes.
[colors]
ring = "neutral-400"

# ...and are overridden per scheme. Same cascade as shadcn's `:root` and `.dark`.
[colors.light]
background = "oklch(1 0 0)"
foreground = "#0a0a0a"
primary = "#171717"

[colors.dark]
background = "#0a0a0a"
foreground = "#fafafa"
primary = "#e5e5e5"

An unknown scheme ([colors.midnight]) is an error, not a silent drop. So is an unknown base_color.

The radius scale

radius is one number 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 the setting. shadcn's multipliers:

ClassMultiplierAt 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

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. Projects that would rather trade a flash for 169 KB can set font = "system".

All nine weights are declared against the font's wght axis, 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, #12345678, 0a0a0a
oklchoklch(0.145 0 0), oklch(62.8% 0.258 29.23), oklch(1 0 0 / 10%)
Tailwind palette nameneutral-900, blue-600

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


Inline overrides

For a single token, skip the file:

# whitehall.toml
[theme.colors]
primary = "#34d399"
muted-foreground = "neutral-400"

Inline values apply to both schemes and beat theme.toml.


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 uses is declared on <App> in src/main.wh:

<App theme="dark">
  <slot />
</App>
themeMaterial ColorSchemeTheme tokens
"dark"darkColorScheme()[colors.dark]
"light"lightColorScheme()[colors.light]
"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 mode-watcher and next-themes call it. darkMode= is the previous spelling of this prop and still parses; theme= wins if both appear.

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. The status bar icons follow the app's scheme too, not the device's.


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. 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.4composeunstyled-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.
  • 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