Skip to main content

Theme Variants

3 min readStableBeginner

lib/theme-variants.ts registers 26 named palettes. Each one carries a full set of light values, a full set of dark values, and a remap of Tailwind's blue scale, all emitted as CSS custom properties into an inline <style> in the document head. Picking one is a single environment variable: no code, no CSS overrides. What follows covers the names, the stop-to-role mapping, and everything else that setting drives.

Quick Start

Leave the default in place and the site renders in violet.

.env.local
Bash
THEME_VARIANT=violet

The server reads the variable once at startup, so restart npm run dev after changing it.

Adding Options

Every name below is a valid value. The resolver matches names in lowercase, so AMBER and amber both land on the same palette.

violet

Default. Own OKLCH scale at hue 296, the OwnDocs brand hue.

amber

Own OKLCH scale of 11 stops at hue 72.

blue

Own OKLCH scale at hue 250, an actual blue.

cyan

Own OKLCH scale.

emerald

Green-leaning OKLCH scale at hue 162, distinct from green and teal.

fuchsia

OKLCH scale shared with pink.

green

Own OKLCH scale at hue 148.

indigo

Own OKLCH scale.

lime

Own OKLCH scale.

mauve

Near-neutral scale with a faint magenta cast.

mist

Near-neutral scale with a faint blue cast.

neutral

Same near-neutral scale as zinc.

olive

Near-neutral scale with a faint yellow-green cast.

orange

Own OKLCH scale.

pink

Own OKLCH scale at hue 345.

purple

Own OKLCH scale at hue 308.

red

Own OKLCH scale at hue 25.

rose

Own OKLCH scale at hue 10.

sky

Own OKLCH scale, and an actual blue.

slate

Same near-neutral scale as zinc.

stone

Warm-leaning near-neutral scale.

taupe

Near-neutral scale with a faint warm brown cast.

teal

Own OKLCH scale.

yellow

Own OKLCH scale at hue 95.

zinc

Cool-leaning near-neutral scale.

gray

Same near-neutral scale as zinc.

.env.local
Bash
THEME_VARIANT=slate

Four names resolve to one identical near-neutral scale:

  • gray, neutral, slate, and zinc

Every other name has its own hue. That leaves 23 visually distinct results across the 26 names.

Advanced

Deploy platforms set the same variable, so a Render service picks its palette from render.yaml instead of a local file. An unrecognized name falls back to violet rather than breaking the build.

render.yaml
YAML
envVars:
  - key: THEME_VARIANT
    value: violet

What the chosen variant produces is a single CSS string, formatted here for reading and trimmed to a few properties:

generateThemeCss() output for THEME_VARIANT=blue
CSS
:root {
  --color-bg-primary: #fcfefd;
  --color-accent: oklch(53.5% 0.1425 250);
  --owndocs-brand-500: oklch(62.8% 0.15 250);
}
.dark {
  --color-bg-primary: oklch(18% 0.039 250);
  --color-accent: oklch(73.8% 0.1166 250);
}

The generated themes map palette stops to roles the same way every time: light mode takes its surfaces from stops 50 and 100, borders from 200 and 300, and the accent from 600 with 700 on hover. Dark mode takes surfaces from 950, 900, and 800, borders from 800 and 700, and the accent from 400 with 300 on hover. Every theme, including emerald, goes through the same buildThemeFromPalette() function. None of them are hand-written exceptions.

Two more things ride on the same setting:

  • The dark-mode toggle is separate from the palette. Readers pick light or dark themselves; the toggle stores that choice in localStorage under docs-theme-mode and flips the .dark class on <html>. The variant supplies the colors for both sides of that switch.
  • Open Graph images read the same variant. app/og/route.tsx pulls --color-bg-primary, --color-text-primary, --color-text-tertiary, --color-accent, and --color-border-primary straight off it, so social cards match the site.

Components never reference a palette directly. They use the semantic aliases: surface-primary, surface-secondary, surface-tertiary, content-primary, content-secondary, content-tertiary, edge-primary, edge-secondary, and brand, each resolving to whichever variant is loaded.

Options

THEME_VARIANTenvDefault: violet

One of the 26 palette names. Matched case-insensitively; anything unrecognized falls back to violet.

AVAILABLE_THEMESstring[]

The 26 registered names, exported from lib/theme-variants.ts for anything that needs to list or validate them.

DEFAULT_THEMEstringDefault: violet

The fallback used when THEME_VARIANT is unset or unknown.

getThemeVariant(name?: string) => ThemeVariant

Resolves a name to its variant, lowercasing first and falling back to the default. Used by both the root layout and the Open Graph image route.

generateThemeCss(theme: ThemeVariant) => string

Serializes a variant into the :root { ... } .dark { ... } string injected into the document head.

lightRecord<string, string>

The 16 light-mode custom properties on a variant, including --color-api-code-bg.

darkRecord<string, string>

The dark-mode custom properties, matching light key for key.

blueRecord<string, string>

Eleven --color-blue-* overrides that repoint Tailwind's blue scale at the variant's own stops, so existing blue utility classes follow the theme.

Was this page helpful?

© 2026 DevOps. All rights reserved.