place

Theming & dark mode

place ships a typed theming primitive. theme() is the canonical entry-point — bare color keys, auto-derived sibling tokens, and (for the common 2-mode case) automatic light-dark() emission. It produces a Tailwind v4 @theme block, per-theme CSS-variable classes, and a typed JS object exposing the raw values. themeTokens() is the low-level primitive underneath — reach for it only when you need non-color --* variables.

1. Declare your theme

ts
// theme.ts — declare your color modes once. theme() is the // canonical entry-point: bare color keys (no `--color-` prefix), // tasteful sibling tokens auto-derived, and — for the common 2-mode // case — automatic light-dark() emission so theme switching is a // single CSS property, zero JS plumbing. import { theme } from '@place/component' export const tokens = theme({ modes: { light: { bg: 'oklch(0.98 0.005 100)', fg: 'oklch(0.18 0.01 280)', accent: 'oklch(0.62 0.18 30)', }, dark: { bg: 'oklch(0.13 0.01 280)', fg: 'oklch(0.95 0.005 100)', accent: 'oklch(0.78 0.16 30)', }, }, default: 'dark', }) // theme() auto-fills the sibling tokens you didn't list — card, // card-fg, border, muted, accent-fg, success, warn, destructive // (+ -fg pairs) — via color-mix() over your anchors. Override any // of them by listing the key explicitly in a mode. Each key emits // `--color-<key>`, so Tailwind v4 generates bg-bg, text-fg, // bg-accent, border-border, text-muted … utilities automatically.
Why oklch?
Themes that use perceptual color (oklch / oklab) interpolate cleanly across hue and lightness. theme() derives siblings via color-mix(in oklab, …) — staying in gamut and looking correct in light + dark from the same anchor values.
Auto light-dark() mode
When you pass exactly two modes named light and dark, theme() auto-selects light-dark() output: one --token: light-dark(lightVal, darkVal) per token plus color-scheme: light dark on :root. Theme switching becomes a single CSS property — no .theme-X class proliferation, no JS theme-provider. More modes, or modes not named light/dark, fall back to the classic class-based mode.

2. Typography

theme() and themeTokens() take an optional typography config — a modular type scale, font families / weights, leading and tracking scales, plus semantic role utility classes (.text-display, .text-h1.text-body, .text-meta) emitted into the same stylesheet as the color tokens.

ts
// theme() (and themeTokens()) take an optional `typography` config. // It emits a modular type scale, font families / weights, leading + // tracking scales, and semantic role utility classes // (.text-display, .text-h1 … .text-body, .text-meta). export const tokens = theme({ modes: { light: { /* … */ }, dark: { /* … */ } }, default: 'dark', typography: { scale: 'major-third', // named ratio (1.25) or a raw number base: 16, // base font size in px // family / weight / leading / tracking / roles all overridable; // omitted ones fall back to tasteful system defaults. }, }) // Then in markup — role classes compose size + leading + tracking // + weight + family; color is left to bg-*/text-* so they compose: <h1 class="text-h1 text-fg">Title</h1> <p class="text-body text-muted">Body copy.</p>

3. Wire into app()

ts
// app.ts — pass the tokens to app({ theme }). The framework wires: // - the @theme block into Tailwind (bg-accent, text-fg, … utilities) // - <html class="theme-…"> on SSR'd output (reads the theme cookie) // - the no-flash early script (see below) into every page <head> import { app } from '@place/component' import { tokens } from './theme.ts' app({ pages: [...], theme: tokens, }).run()

4. No-flash persistence is automatic

When theme is passed to app(), the framework injects themeEarlyScript() into every page's <head> automatically — apps get no-flash theme persistence for free. It runs before <body> parses, reads the theme cookie, and applies the matching class. Works on a live server and on a static export from app().build(), where there's no per-request cookie read at SSR time.

ts
// You write nothing for no-flash theme persistence — when `theme` // is passed to app(), the framework injects themeEarlyScript() // into every page's <head> automatically. It runs BEFORE <body> // parses: reads the theme cookie, applies the matching theme-* // class (or none, for 'system'), mirrors the choice onto // <html data-place-theme="…">. Works on a live server AND on a // static export from app().build(). // Calling it by hand is only needed if you're not using app({theme}): import { themeEarlyScript } from '@place/component' import { tokens } from './theme.ts' const earlyJs = themeEarlyScript(tokens) // raw JS statement string

5. Switch themes

setTheme(tokens, theme, options?) flips the active theme: it strips every theme-* class, adds the chosen one, mirrors the choice onto <html data-place-theme>, and writes the cookie. The theme argument accepts a mode name or the special string 'system' (which clears every class so the OS preference drives appearance). There is no activeTheme export — read the current choice off <html data-place-theme>.

ts
// theme-toggle.tsx — flip the theme. setTheme(tokens, name) // strips every theme-* class, adds the chosen one, mirrors it to // <html data-place-theme>, and writes the cookie — all in one tick. // // Signature: setTheme(tokens, theme, options?) // - `tokens`: the theme()/themeTokens() result // - `theme`: a mode name OR the special string 'system' // - `options?`: { cookieName? } // // There is no `activeTheme` export — read the current choice off // <html data-place-theme> (the early script + setTheme keep it // current) or track your own state cell. import { setTheme } from '@place/component' import { tokens } from './theme.ts' export const ThemeToggle = component(() => { const current = () => document.documentElement.dataset['placeTheme'] ?? tokens.default return ( <button aria-label="Toggle theme" onClick={() => setTheme(tokens, current() === 'dark' ? 'light' : 'dark')} > {() => (current() === 'dark' ? '☾' : '☀')} </button> ) }) // 'system' clears every theme class so the stylesheet's // prefers-color-scheme bindings drive appearance from the OS: <button onClick={() => setTheme(tokens, 'system')}>Match system</button>

6. Use in components

ts
// Use semantic Tailwind classes — they're bound to the CSS // variables the theme emits. Theme switching is invisible to // components: the class on <html> changes, the variables resolve // differently, every utility re-skins atomically. <div class="bg-card border border-border text-fg"> <h1 class="text-fg">Title</h1> <p class="text-muted">Subtitle</p> <button class="bg-accent text-accent-fg">Primary</button> </div> // Or read the typed tokens object for non-Tailwind code (canvas, // motion interpolation between OKLCH values, server-side meta tags): import { tokens } from './theme.ts' console.log(tokens.themes.dark['--color-accent']) // 'oklch(0.78 0.16 30)'

Per-subtree override

CSS custom properties cascade. To force a region into a different theme — a dark callout inside a light page, a light preview inside a dark editor — drop the theme class on any element. Descendants inherit the new variables; semantic utilities (bg-bg, text-fg) read from the closest theme block automatically.

ts
// Per-subtree theme override — drop the theme class on any element. // CSS custom properties cascade to descendants; bg-*/text-* // utilities read from whichever theme block is closest. Lets you // nest a dark callout inside a light page (or vice versa). <section class={tokens.htmlClass('dark')}> <h2 class="text-fg">Always dark</h2> <p class="text-muted bg-card">Even if the page is in light mode.</p> </section>

The low-level primitive: themeTokens()

theme() wraps themeTokens(). Reach for the primitive directly only when you need to emit arbitrary --* CSS variables that aren't colors (custom --shadow-*, --radius-*) or when you're authoring your own theme()-shaped helper. It has the same return shape, so it drops into app({ theme }) unchanged.

ts
// themeTokens() — the low-level primitive theme() wraps. Reach for // it directly only when you need to set arbitrary --* CSS variables // that AREN'T colors (custom --shadow-*, --radius-*), or when // authoring your own theme()-shaped helper. // // You write the full --color-* token keys; every theme must declare // the SAME key set (a missing key is a type error at the call site). import { themeTokens } from '@place/component' export const tokens = themeTokens({ themes: { light: { '--color-bg': 'oklch(0.98 0.005 100)', '--color-fg': 'oklch(0.18 0.01 280)', '--color-accent': 'oklch(0.62 0.18 30)', '--radius-card': '0.75rem', }, dark: { '--color-bg': 'oklch(0.13 0.01 280)', '--color-fg': 'oklch(0.95 0.005 100)', '--color-accent': 'oklch(0.78 0.16 30)', '--radius-card': '0.75rem', }, }, default: 'dark', typography: { scale: 'major-third' }, // same config as theme() })

Custom themes

ts
// More than two modes, or modes not named light/dark? theme() // stays in classic 'classes' mode (one .theme-<name> class per // mode, swapped via the cookie). Add as many as you want. theme({ modes: { light: { bg: '…', fg: '…', accent: '…' }, dark: { bg: '…', fg: '…', accent: '…' }, sepia: { bg: 'oklch(0.93 0.04 80)', fg: 'oklch(0.20 0.04 60)', accent: '…' }, 'high-contrast': { bg: '#000', fg: '#fff', accent: '#ff0' }, }, default: 'dark', })

What you DON'T do

  • No ThemeProvider. The theme isn't a context; it's a class on <html> plus a cookie.
  • No JS theme prop on every component. Components use semantic Tailwind classes (bg-accent); the variables behind them are theme-dependent.
  • No CSS-in-JS runtime. Tokens compile to CSS variables at build; theme switching is a class swap (or, in light-dark() mode, a single CSS property).
  • No hand-written pre-paint script. themeEarlyScript() is injected for you when theme is passed to app().