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
// 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-ts/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.
theme() derives siblings via color-mix(in oklab, …) — staying in gamut and looking correct in light + dark from the same anchor values.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.
// 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()
// 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-ts/component/server'
import { tokens } from './theme.ts'
app({
pages: [...],
theme: tokens,
}).start()
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.
// 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 place-theme cookie
// - explicit mode (light/dark/...) → adds the theme-* class
// - 'system' or no cookie → NO class on <html>; @media drives
// - mirrors the choice onto <html data-place-theme="…">
// - stashes { names, classes, cookieName } on window.__placeTheme
// so useTheme() and setTheme(name) work without importing tokens
//
// As of 0.10.1, SSR also ships no theme class for absent / 'system'
// cookies — so the @media bindings drive from first paint. Zero
// flicker on hard refresh, no class-swap window.
//
// 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-ts/component'
import { tokens } from './theme.ts'
const earlyJs = themeEarlyScript(tokens) // raw JS statement string
5. Switch themes — four customization tiers
place ships four tiers for theme switching, each one line of code longer than the last. Pick your tier. Each one drops one layer of abstraction in exchange for one more line of code. There is no opinionated middle ground we force on you.
Tier 1 — defaults
Drop <ThemeToggle /> from @place-ts/design in your layout and you're done. A segmented control with System · Light · Dark, theme-token-aware, with cookie persistence and zero-flash hard-refresh.
// Tier 1 — defaults. Drop one tag in your layout.
import { ThemeToggle } from '@place-ts/design'
// Renders a segmented control: System · Light · Dark.
// Persists choice in the place-theme cookie. Survives hard refresh
// with zero flash (the framework's early-paint script). Multi-instance
// sync built-in — two <ThemeToggle/>s on the same page stay aligned.
<ThemeToggle />
Tier 2 — tweak presentation via props
Same component, more knobs. Switch to a single-button cycle, hide the system option, supply a custom mode list, override per-mode labels and icons, attach additive Tailwind classes.
// Tier 2 — same component, tweak presentation via props.
import { ThemeToggle } from '@place-ts/design'
<ThemeToggle
variant="cycle" // 'segmented' (default) | 'cycle'
size="sm" // 'sm' | 'md' (default) | 'lg'
includeSystem={false} // hide the system option
modes={['light', 'dark', 'sepia']} // restrict / override mode list
labels={{
system: 'Auto',
light: 'Day',
dark: 'Night',
}}
icons={{
system: <DesktopIcon />, // any JSX View
light: <SunIcon />,
dark: <MoonIcon />,
}}
class="ml-auto" // additive Tailwind (cls()-merged)
aria-label="Theme"
/>
Tier 3 — bring your own UI
The headless primitive — useTheme() from @place-ts/component — returns the reactive current choice + a setter + the configured mode list. Build any UI you want. This is the answer when the design-system styled <ThemeToggle> doesn't fit. No need to import the app's tokens object — the framework's early-paint script stashes everything setTheme / useTheme need onwindow.__placeTheme.
// Tier 3 — bring your own UI. The headless hook from the framework
// returns reactive state + actions; you render whatever you want.
//
// theme.current() reactive getter — 'system' | one of theme.modes
// theme.set(name) swaps class + persists cookie + fires sync event
// theme.modes configured mode names from app({ theme })
// theme.isSystem() shorthand for theme.current() === 'system'
import { useTheme } from '@place-ts/component'
export default island(import.meta.url, () => {
const theme = useTheme()
return (
<select
value={() => theme.current()}
onChange={(e) => theme.set((e.currentTarget as HTMLSelectElement).value)}
>
<option value="system">System</option>
{theme.modes.map((m) => <option value={m}>{m}</option>)}
</select>
)
})
// Multiple useTheme() consumers on the same page stay in sync — set()
// dispatches a 'place:theme-changed' CustomEvent on window, and every
// other useTheme() handle bumps its reactive cell automatically.
Tier 4 — escape hatch
Direct setTheme(name) call. No reactive state, no component. Useful for one-off toggle buttons buried in onboarding flows or form-submit handlers.
// Tier 4 — escape hatch. Direct setTheme() call. No reactive state.
// Useful in handlers far from JSX, in onboarding flows, in form
// submit callbacks. The string-only overload reads theme info from
// the framework's window stash — no tokens import needed.
import { setTheme } from '@place-ts/component'
<button onClick={() => setTheme('dark')}>Force dark</button>
<button onClick={() => setTheme('system')}>Match OS</button>
// Original tokens-explicit form still works:
import { tokens } from './theme.ts'
setTheme(tokens, 'dark')
place-theme cookie), the framework SSRs no theme class on <html>. The stylesheet's @media (prefers-color-scheme: …) bindings then drive appearance from the OS preference, with zero JS and zero flash. Users only see your configured default mode if they explicitly pick it from the toggle. This is the 0.10.1 SSR blip fix — earlier versions sent the default class and the early-paint script had to swap it out, producing a visible flicker when the OS preference differed from the configured default.6. Use in components
// 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.
// 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.
// 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-ts/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
// 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 whenthemeis passed toapp().