Theming

Koala is themed entirely through CSS variables. Components read semantic tokens, never a raw color, radius or shadow, so one block of variables restyles the whole system.

Theme

Accent · --brand

Radius · --radius

Density

WorkspacePro
Every surface, control and focus ring below reads the same tokens.
Volume68

Drop this into your global stylesheet to ship the look above.

globals.cssCSS
/* The orange accent ships as a preset: set it on the html element. *//* <html data-accent="orange"> */:root {  --radius: 1rem;}

How theming works#

Every Koala component is built from semantic color roles (bg-background, text-foreground, bg-primary,border-border …), a single radius knob (--radius) and a single accent knob (--brand). None of those are hard-coded in a component - they resolve to CSS variables defined once per theme. Switch the variables, and every surface, control and focus ring follows.

The roles themselves are documented on the Colors page. This page is about changing them.

how a component resolves a colorCSS
/* A button reads a role, never a literal: */.button { background: var(--primary); }/* …and the role is defined once, per theme: */:root      { --primary: oklch(0.205 0 0); }    /* light     */.cream     { --primary: oklch(0.24 0.014 75); } /* cream     */.dark      { --primary: oklch(0.922 0 0); }    /* dark      */.moonlight { --primary: oklch(0.704 0.14 264); } /* moonlight */

The four built-in themes#

Koala ships light, cream (warm light), dark and moonlight (blue-tinted dark). Each is a class on <html>; light is the bare :root, so it needs no class. We drive the class with next-themes through a thin ThemeProvider.

app/layout.tsxTSX
import { ThemeProvider } from "@/components/theme-provider"export default function RootLayout({ children }) {  return (    <html lang="en" suppressHydrationWarning>      <body>        {/* attribute="class" · themes: light, cream, dark, moonlight */}        <ThemeProvider>{children}</ThemeProvider>      </body>    </html>  )}

Theme and accent are orthogonal axes: any of the nine accents works under any of the four themes, because the themes deliberately never redefine --brand.

Accent color#

The accent is one variable, --brand. Every component reads it through bg-brand / border-brand / ring-brand, so a single value recolors checkboxes, switches, sliders, focus rings and links at once. Nine presets ship as [data-accent] blocks - apply one by setting the attribute on <html>:

<html data-accent="violet">

For a brand color outside the presets, set --brand yourself. The focus ring (--ring-brand) derives from it automatically. Eight presets are mid-tone and carry white labels; lime is light, so its preset follows the theme (a deeper lime on light, the vivid one on dark) and sets --brand-foreground, the ink that sits on a brand fill. A light brand color of your own needs the same: set --brand-foreground to a dark ink.

globals.cssCSS
:root {  --brand: oklch(0.55 0.2 255); /* or any hex / rgb, color-mix converts it */}

Corner radius#

One knob, --radius, drives the entire rounding scale. rounded-lg is --radius itself; rounded-sm … rounded-4xl are relative multiples of it, so changing the base re-rounds every component concentrically.

globals.cssCSS
:root {  --radius: 0.5rem; /* default 0.71rem, set 0 for sharp corners */}

Density#

Density is Koala’s spacing axis - the knob that retunes padding, gaps and control heights without touching color or radius. Koala serves both information-dense application UI and generous marketing surfaces, so density lets one subtree tighten or relax with no per-instance props. There are two values: compact (the default - 16px padding/gaps, smaller titles, shorter controls) and comfortable (the spacious alternative).

Wrap a subtree in DensityProvider and every nested Koala component follows. The wrapper is display: contents, so it never introduces a box into the layout, and it stamps data-density on itself as a CSS/debug escape hatch.

app shellTSX
import { DensityProvider } from "@/lib/density"export default function AppShell({ children }) {  return (    // Every Koala component inside tightens up.    <DensityProvider density="compact">      {children}    </DensityProvider>  )}

A component resolves its effective density with useDensity(). Precedence is explicit prop > nearest provider > compact, and the result feeds the component’s tv recipe. Unlike most Koala contexts, density has a safe default, so it works with no provider at all.

inside a componentTSX
import { useDensity, type Density } from "@/lib/density"function Card({ density }: { density?: Density }) {  // explicit prop > nearest provider > "compact"  const resolved = useDensity(density)  return <div className={card({ density: resolved })} />}

Density is orthogonal to theme, accent and radius - it changes only spacing, so any density composes with any theme or accent.

Create your own theme#

A theme is just a class that redefines the role variables. The fastest path is to copy an existing block (light or dark, whichever is closer) and retune the values - that guarantees you cover every role a component might read.

app/globals.cssCSS
.sunset {  /* Surfaces */  --background: oklch(0.98 0.02 70);  --foreground: oklch(0.25 0.03 50);  --card: oklch(1 0.01 70);  --card-foreground: oklch(0.25 0.03 50);  --popover: oklch(1 0.01 70);  --popover-foreground: oklch(0.25 0.03 50);  /* Brand-neutral actions + muted/accent fills */  --primary: oklch(0.45 0.15 30);  --primary-foreground: oklch(0.98 0.02 70);  --secondary: oklch(0.94 0.03 70);  --secondary-foreground: oklch(0.3 0.04 40);  --muted: oklch(0.94 0.03 70);  --muted-foreground: oklch(0.52 0.03 55);  --accent: oklch(0.92 0.04 65);  --accent-foreground: oklch(0.3 0.04 40);  /* Status, lines + focus */  --destructive: oklch(0.577 0.245 27.325);  --border: oklch(0.89 0.03 70);  --border-soft: color-mix(in oklch, var(--border) 50%, transparent);  --input: oklch(0.89 0.03 70);  --ring: oklch(0.7 0.05 60);  /* …also copy the success/warning/info, categorical hue,     --syntax-* and per-theme --shadow-* roles from a sibling block. */}

Then register the name so next-themes can apply it, and it shows up in any theme switcher built from THEMES:

components/theme-provider.tsxTSX
export const THEMES = ["light", "cream", "dark", "moonlight", "sunset"] as const

Tip: a colored container (a tinted hero, a brand panel) should declare --surface rather than paint a --background block, so nested Inputs, Selects and minimal Tables blend into it instead of cutting their own surface.

Switching themes at runtime#

Because the theme is a class, swapping it live is just setTheme() from next-themes. The header switcher on this site dogfoods exactly this with a DropdownMenu radio group. Read resolvedTheme rather than theme, so a visitor on the system preference still sees a row checked:

theme-switcher.tsxTSX
"use client"import { useTheme } from "next-themes"import { THEMES } from "@/components/theme-provider"import { Button } from "@/components/ui/button"import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuRadioGroup,  DropdownMenuRadioItem,  DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"export function ThemeSwitcher() {  const { resolvedTheme, setTheme } = useTheme()  return (    <DropdownMenu>      <DropdownMenuTrigger asChild>        <Button variant="ghost" size="sm">Theme</Button>      </DropdownMenuTrigger>      <DropdownMenuContent align="end">        <DropdownMenuRadioGroup value={resolvedTheme} onValueChange={setTheme}>          {THEMES.map((t) => (            <DropdownMenuRadioItem key={t} value={t} className="capitalize">              {t}            </DropdownMenuRadioItem>          ))}        </DropdownMenuRadioGroup>      </DropdownMenuContent>    </DropdownMenu>  )}