Theming
Roadie's ThemeProvider wires one hex accent colour into 14-step OKLCH scales at runtime and handles dark mode at the same time. Use it uncontrolled for apps with a single brand colour, controlled for apps themed from data, or compose the pre-hydration bootstrap helpers for static exports that need zero-flash cold loads.
Concepts
Accent colour. A single hex string drives two CSS custom properties —
--accent-hueand--accent-chroma— which Roadie's CSS tokens feed intooklch()curves. Changing the accent updates every component that reads theaccent-*scale through the cascade; there are no component-level theme props.Dark mode. The
.darkclass on<html>swaps a second set of OKLCH values.ThemeProviderhandles the toggle, localStorage persistence, and optionalprefers-color-schemefollowing. Dark mode and accent colour are independent — setting one never resets the other.Intent cascade. Intent (neutral, brand, accent, danger, etc.) is set via
intent-*utility classes and flows down through CSS custom properties. Child components inherit automatically — no context providers per intent.OKLCH curves. All 14 steps per intent are computed from a single hue + chroma using hand-tuned lightness / chroma curves. The math happens at CSS resolve time, not in JavaScript, so the scale stays perfectly consistent across reflows.
Static theming (uncontrolled)
For apps with a single brand colour that never changes at runtime, wrap the root layout in ThemeProvider and pass defaultAccentColor. Children can still call useTheme().setAccentColor(hex) imperatively — e.g. an in-app colour picker — and the internal state tracks the change.
Dynamic theming (controlled)
When the accent colour comes from async data — a CMS field, a feature flag, per-tenant config — pass it as the accentColor prop. The provider becomes controlled: the prop wins on every render, and imperative setAccentColor calls become no-ops with a dev warning. Pass null to opt into controlled mode while falling back to defaultAccentColor.
The provider re-renders whenever the prop changes — no useEffect, no manual cleanup, no reset logic. Passing null while the query is loading falls back to defaultAccentColor, so the theme never renders in a broken state during the suspense boundary. The old CollectionAccentSync-style effect helper is unnecessary — consumer apps can delete their bespoke effect-plus-cleanup wiring as soon as they adopt the controlled prop.
Validation & error handling
Roadie validates hex input at two points:
setAccentColor(hex)throws synchronously with anInvalidColorErrorwhen the argument isn't a valid hex colour. Wrap the call in a try/catch or validate upfront withisValidHexColor.Invalid controlled props log and fall back. When
accentColoris a non-hex string, the provider logs a dev warning and renders withdefaultAccentColorso the app never shows a broken theme.
Pre-hydration bootstrap
React hydration runs after the first paint, so an app that only sets the accent inside ThemeProvider will flash the default blue for ~200–400ms on cold loads. For apps that know the accent colour on the server — per-tenant branding, promoter-branded pages, SSG routes — use the synchronous bootstrap helpers to inject --accent-hue and --accent-chroma before the first paint.
Option 1 — React (<head> injection)
For React frameworks (Next.js, Remix, etc.), inject the theme script and accent style as separate head children.getAccentStyleSync returns just the inner CSS body so you can wrap it in a real <style> element.
Option 2 — Framework-agnostic (getBootstrapScript)
For Astro, Nuxt, or plain HTML, use getBootstrapScript. It returns a raw HTML string combining both the theme script and the accent style tag, ready to drop into <head>.
Both paths use the same synchronous sRGB → OKLCH converter internally and match colorjs.io's output to four decimal places. Modern browsers (Chrome 111+, Safari 15.4+, Firefox 113+) render the accent scale from oklch() math directly, so the two custom properties are sufficient for a zero-flash cold load. Older browsers fall back to the compiled CSS accent until the client-side accent effect runs.
Resetting to the default
Roadie exports DEFAULT_ACCENT_COLOR so consumers never hardcode the default hex. Use it three ways:
In a controlled provider: pass
accentColor={someHex ?? null}— Roadie coercesnullback to the default.In an uncontrolled provider: call
setAccentColor(DEFAULT_ACCENT_COLOR)from a reset button.In the bootstrap helpers: omit
accentColoror passnull— the default CSS accent applies and the bootstrap emits no style tag.
Dark mode integration
Accent colour and dark mode are independent but share a single provider. getThemeScript prevents the flash-of-wrong-theme on initial load by reading localStorage before the first paint; getBootstrapScript composes that with the accent style tag into one head injection.
followSystem— respectprefers-color-schemeuntil the user explicitly toggles.defaultDark— initial dark state when no preference is stored.useTheme().setDark(boolean)— persist an explicit choice tolocalStorage.useTheme().isDark— current state, reactive.
Recipes
Theme from a tanstack-query hook
Fetch the accent, render the provider with accentColor, let loading states flow through null. No effects, no cleanup.
Per-route theming with Next.js App Router
A per-route layout.tsx can fetch its own data and wrap children in a scoped provider. Nesting overrides the parent provider — no global state to reset on navigation.
Static export with server-prefetched accent
For output: 'export' apps, inject the accent style tag during the server pass so the first paint already uses the themed colours.
Reference
ThemeProvider— root provider for accent + dark mode. AcceptsaccentColor,defaultAccentColor,defaultDark,followSystem.useTheme()— returns{ accentColor, setAccentColor, isDark, setDark }.DEFAULT_ACCENT_COLOR— exported constant for the Oztix blue default.isValidHexColor(input)— sync type-guard for fetch-boundary validation.InvalidColorError— thrown bysetAccentColor,getAccentStyleTagSync, andgetAccentStyleSyncon invalid input.getThemeScript(opts)— inline script body for dark-mode flash prevention.getAccentStyleSync(hex)— inner CSS body (React-friendly).getAccentStyleTagSync(hex)— full<style>tag (framework-agnostic).getAccentStyleTag(hex)— async variant with full hex fallbacks for non-OKLCH browsers.getBootstrapScript(opts)— unified helper combining theme script and accent style tag for a single head injection.
Next steps: view transitions for cross-route animation, or the colors page for the surface tokens the accent flows into.