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-hue and --accent-chroma — which Roadie's CSS tokens feed into oklch() curves. Changing the accent updates every component that reads the accent-* scale through the cascade; there are no component-level theme props.

  • Dark mode. The .dark class on <html> swaps a second set of OKLCH values. ThemeProvider handles the toggle, localStorage persistence, and optional prefers-color-scheme following. 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.

// app/layout.tsx
import { ThemeProvider } from '@oztix/roadie-components'
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<ThemeProvider defaultAccentColor="#7C3AED" followSystem>
{children}
</ThemeProvider>
</body>
</html>
)
}

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.

// app/collections/[slug]/page.tsx
'use client'
import { useCollection } from '@/hooks/useCollection'
import { ThemeProvider } from '@oztix/roadie-components'
export default function CollectionPage({ params }) {
const { data } = useCollection(params.slug)
return (
<ThemeProvider accentColor={data?.themeColour ?? null}>
<CollectionView collection={data} />
</ThemeProvider>
)
}

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 an InvalidColorError when the argument isn't a valid hex colour. Wrap the call in a try/catch or validate upfront with isValidHexColor.

  • Invalid controlled props log and fall back. When accentColor is a non-hex string, the provider logs a dev warning and renders with defaultAccentColor so the app never shows a broken theme.

import {
DEFAULT_ACCENT_COLOR,
InvalidColorError,
ThemeProvider,
isValidHexColor
} from '@oztix/roadie-components'
function CollectionTheme({ collection, children }) {
// Guard untrusted input at the fetch boundary
const accent = isValidHexColor(collection.themeColour)
? collection.themeColour
: DEFAULT_ACCENT_COLOR
return <ThemeProvider accentColor={accent}>{children}</ThemeProvider>
}
// Imperative calls throw synchronously on invalid input
try {
setAccentColor(userInput)
} catch (error) {
if (error instanceof InvalidColorError) {
toast.error('That colour isn\'t a valid hex value.')
}
}

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.

// app/layout.tsx
import {
getAccentStyleSync,
getThemeScript
} from '@oztix/roadie-components'
export default async function RootLayout({ children }) {
const collection = await fetchCollection()
const accentHex = collection?.themeColour ?? null
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: getThemeScript({ followSystem: true })
}}
/>
{accentHex && (
<style
id="roadie-accent-theme"
dangerouslySetInnerHTML={{
__html: getAccentStyleSync(accentHex)
}}
/>
)}
</head>
<body>
<ThemeProvider accentColor={accentHex}>{children}</ThemeProvider>
</body>
</html>
)
}

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>.

// astro.page.astro
---
import { getBootstrapScript } from '@oztix/roadie-core/theme'
const html = getBootstrapScript({
followSystem: true,
accentColor: Astro.props.collection?.themeColour
})
---
<head>
<Fragment set:html={html} />
</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 coerces null back to the default.

  • In an uncontrolled provider: call setAccentColor(DEFAULT_ACCENT_COLOR) from a reset button.

  • In the bootstrap helpers: omit accentColor or pass null — 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.

<ThemeProvider
accentColor={collection?.themeColour ?? null}
followSystem
defaultDark={false}
>
{children}
</ThemeProvider>
  • followSystem — respect prefers-color-scheme until the user explicitly toggles.
  • defaultDark — initial dark state when no preference is stored.
  • useTheme().setDark(boolean) — persist an explicit choice to localStorage.
  • 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.

function CollectionTheme({ slug, children }) {
const { data } = useCollection(slug)
return (
<ThemeProvider accentColor={data?.themeColour ?? null}>
{children}
</ThemeProvider>
)
}

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.

// app/collections/[slug]/layout.tsx
export default async function CollectionLayout({ params, children }) {
const { slug } = await params
const collection = await getCollection(slug)
return (
<ThemeProvider accentColor={collection.themeColour ?? null}>
{children}
</ThemeProvider>
)
}

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.

// app/collections/[slug]/page.tsx
import { getAccentStyleSync } from '@oztix/roadie-components'
export async function generateStaticParams() {
return (await listCollections()).map((c) => ({ slug: c.slug }))
}
export default async function CollectionPage({ params }) {
const { slug } = await params
const collection = await getCollection(slug)
const css = collection.themeColour
? getAccentStyleSync(collection.themeColour)
: null
return (
<>
{css && (
<style
id="roadie-accent-theme"
dangerouslySetInnerHTML={{ __html: css }}
/>
)}
<CollectionView collection={collection} />
</>
)
}

Reference

  • ThemeProvider — root provider for accent + dark mode. Accepts accentColor, 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 by setAccentColor, getAccentStyleTagSync, and getAccentStyleSync on 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.