RoadieRoadie
  • Home
  • Brand
  • Foundations
  • Content
  • Tokens
  • Components
  • Charts
  • Widgets
  • Appearance
HomeBrandFoundations
Appearance

Foundations

  • Visual

    • Colors
    • Elevation
    • Iconography
    • Shape
    • Theming
  • Content

    • Date and time
    • Layout
    • Prose styles
    • Typography
  • Behaviour

    • Accessibility
    • Interactions
    • Motion
    • View transitions
  • Building apps

    • Forms
    • Linking
    • Navigation
    • Performance
    • Records modelAlpha
    • Tables

Theming

Docs updated
10 Oct 2026

ReferenceColor scales tokens

Roadie's ThemeProvider turns one hex accent colour into 14-step OKLCH scales at runtime and handles dark mode at the same time. RoadieProvider mounts it at the root, and its theme prop takes the provider's options. Leave the accent alone for apps with a single brand colour, control it for apps themed from data, or add the pre-hydration bootstrap helpers for static exports that need cold loads with no flash. The Colors page covers the surface tokens the accent flows into.

Theming model

  • The accent colour is one hex string that drives two CSS custom properties, --accent-hue and --accent-chroma, which Roadie's CSS feeds into oklch() curves. Changing the accent updates every component that reads the accent or neutral scale through the cascade. There are no component-level theme props.
  • Dark mode is the .dark class on <html>, which swaps a second set of OKLCH values. ThemeProvider handles the toggle, localStorage persistence, and optionally following prefers-color-scheme. Dark mode and accent colour are independent, and setting one never resets the other.
  • Intent, such as neutral, brand, accent, or danger, is set with intent-* utility classes and flows down through CSS custom properties. Child components inherit it, with no context provider per intent.
  • The accent and neutral scales are OKLCH curves computed from the one hue and chroma, with hand-tuned lightness and chroma. The other intents' scales are fixed, and in charts only --chart-highlight follows the accent. The math happens when CSS resolves, not in JavaScript, so the scales stay consistent across reflows.

Accent scales

On the docs site, DEFAULT_ACCENT_COLOR and the CSS defaults of the two accent properties are listed, followed by every colour scale that reads --accent-hue, live in the current theme. Pick an accent in the appearance settings and they change with it.

DEFAULT_ACCENT_COLOR
#0191eb
--accent-hue
247
--accent-chroma
0.168
  • Neutral

    1. 0
    2. 1
    3. 2
    4. 3
    5. 4
    6. 5
    7. 6
    8. 7
    9. 8
    10. 9
    11. 10
    12. 11
    13. 12
    14. 13
  • Accent

    1. 0
    2. 1
    3. 2
    4. 3
    5. 4
    6. 5
    7. 6
    8. 7
    9. 8
    10. 9
    11. 10
    12. 11
    13. 12
    14. 13

Static theming

For apps with a single brand colour that never changes at runtime, pass defaultAccentColor in RoadieProvider's theme. Children can still call useTheme().setAccentColor(hex), such as from an in-app colour picker, and the provider's own state tracks the change.

// app/providers.tsx
'use client'
import type { ReactNode } from 'react'
import NextLink from 'next/link'

Dynamic theming

When the accent colour comes from async data, such as a CMS field, a feature flag, or per-tenant config, pass it as accentColor. The provider becomes controlled: the prop wins on every render, and setAccentColor calls do nothing but log a dev warning. Pass null to stay controlled while falling back to defaultAccentColor. Without a defaultAccentColor, null falls back to Oztix blue in the root provider and to the parent's accent in a nested one.

// app/providers.tsx
'use client'
import type { ReactNode } from 'react'
import NextLink from 'next/link'

The provider follows the prop whenever it changes, with no useEffect, cleanup, or reset logic. Passing null while the query loads falls back to defaultAccentColor, so the theme never renders in a broken state.

A nested provider has no default of its own: with null, no accent, or an invalid one, it inherits the accent around it, whether the root's or an enclosing nested provider's, and so do popups that open from inside it. It sets no scope until its accent arrives, and its children don't remount when it does.

Validation and error handling

Roadie validates hex input at two points:

  • setAccentColor(hex) throws an InvalidColorError straight away when the argument isn't a valid hex colour. Wrap the call in a try and catch, or validate first with isValidHexColor.
  • An invalid accentColor prop logs a dev warning, and the provider renders with defaultAccentColor, or the parent's accent when nested, so the app never shows a broken theme.
import {
InvalidColorError,
ThemeProvider,
isValidHexColor
} from '@oztix/roadie-components'
import { DEFAULT_ACCENT_COLOR } from '@oztix/roadie-core/theme'

Pre-hydration bootstrap

React hydrates after the first paint, so an app that only sets the accent inside ThemeProvider flashes the default blue on a cold load until it hydrates. For apps that know the accent colour on the server, such as per-tenant branding, promoter-branded pages, or statically generated routes, use the synchronous bootstrap helpers to set --accent-hue and --accent-chroma before the first paint.

React head injection

For React frameworks, such as Next.js or Remix, inject the theme script and the accent style as separate head children. getAccentStyleSync returns only the inner CSS, so you can wrap it in a real <style> element. Give the root provider the same accent. It keeps a style that already holds its accent, and replaces one that holds another accent once it hydrates.

// app/layout.tsx
import { getAccentStyleSync, getThemeScript } from '@oztix/roadie-components'
import { Providers } from './providers'
export default async function RootLayout({ children }) {

Framework-agnostic bootstrap

For Astro, Nuxt, or plain HTML, use getBootstrapScript. It returns one HTML string with both the theme script and the accent style tag, ready to drop into <head>.

---
// src/pages/[slug].astro
import { getBootstrapScript } from '@oztix/roadie-core/theme'
const html = getBootstrapScript({
followSystem: true,

Both paths use the same synchronous sRGB to OKLCH converter, which matches colorjs.io's lightness and chroma to four decimal places. Modern browsers (Chrome 111+, Safari 15.4+, and Firefox 113+) render the accent scale from oklch() math directly, so the two custom properties are enough for a cold load with no flash. Older browsers fall back to the compiled CSS accent until the client-side accent effect runs.

Resetting to the default

@oztix/roadie-core/theme exports DEFAULT_ACCENT_COLOR, the accent step 9 that the tokens ship, so consumers never hardcode the default hex. @oztix/roadie-components re-exports it. Reset to it three ways:

  • In a controlled root provider, pass accentColor={someHex ?? null}, and Roadie turns null back into the default. A nested provider turns null into its parent's accent, so pass DEFAULT_ACCENT_COLOR to reset one.
  • In an uncontrolled provider, call setAccentColor(DEFAULT_ACCENT_COLOR) from a reset button.
  • In getBootstrapScript, omit accentColor or pass null. The default CSS accent applies, and the bootstrap emits no style tag.

Dark mode

Accent colour and dark mode are independent but share one provider. getThemeScript prevents a flash of the wrong theme on the first load by reading localStorage before the first paint, and getBootstrapScript adds the accent style tag to it for one head injection.

<RoadieProvider
link={NextLink}
theme={{
accentColor: collection?.themeColour ?? null,
followSystem: true,
defaultDark: false
  • followSystem follows prefers-color-scheme until the user toggles.
  • defaultDark sets the dark state when no preference is stored.
  • useTheme().setDark(boolean) persists an explicit choice to localStorage.
  • useTheme().isDark is the current state, and it updates as it changes.

Recipes

Theme from a TanStack Query hook

Fetch the accent, render the provider with accentColor, and let loading states flow through null, with no effects or cleanup. Inside RoadieProvider, a nested provider like this one themes only its own subtree, as per-route theming describes.

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 route's layout.tsx can fetch its own data and wrap its children in a nested ThemeProvider. A nested provider sets the accent on a wrapper around its children, so the route's accent themes only the route, and the root's accent stays on everything outside it. When the route closes, its wrapper goes with it. Menus, dialogs, and other popups that open from the route carry its accent too. The wrapper uses display: contents, so it doesn't change the layout, but a parent's selectors for its direct children, such as *: variants, see the wrapper instead of the route's elements. Inside a list or table, put the provider inside an item or cell, since the wrapper is a div. It renders on the server, so the route's first paint already has its accent. Like the root, it starts its subtree at the neutral intent. Browsers without OKLCH keep the root's accent inside the route. When a route has no themeColour, the provider gets null, and the route keeps the root's accent and the intent around it.

// 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}>

Static export with server-prefetched accent

For output: 'export' apps, wrap the page in a ThemeProvider, nested inside the root one, with the accent you fetch at build time. The wrapper and its accent are in the exported HTML, so the first paint already uses the themed colours, and hydration has nothing to rewrite. For an accent that themes the whole document, use the head injection helpers and give the root provider the same accent instead.

// app/collections/[slug]/page.tsx
import { ThemeProvider } from '@oztix/roadie-components'
export async function generateStaticParams() {
return (await listCollections()).map((c) => ({ slug: c.slug }))
}

Helpers

  • DEFAULT_ACCENT_COLOR is the Oztix blue default, from @oztix/roadie-core/theme.
  • isValidHexColor(input) is a synchronous type guard for validating at the fetch boundary.
  • InvalidColorError is thrown by setAccentColor, getAccentStyleSync, getAccentStyleTagSync, and getAccentStyleTag on invalid input. getBootstrapScript throws a plain Error.
  • getThemeScript(options) returns the inline script body that prevents a dark mode flash.
  • getAccentStyleSync(hex) returns the inner CSS, for React.
  • getAccentStyleTagSync(hex) returns a full <style> tag, for any framework.
  • getAccentStyleTag(hex) is the async variant, with full hex fallbacks for browsers without OKLCH.
  • getBootstrapScript(options) combines the theme script and the accent style tag for one head injection.

Hooks

useTheme

function useTheme(): {
accentColor: string
setAccentColor: (color: string) => void
isDark: boolean
setDark: (dark: boolean) => void
}

Reads and updates the theme mounted by the nearest ThemeProvider, including the one RoadieProvider mounts. Throws outside one.

FieldTypeDescription
accentColorstringThe current accent, as a hex string.
setAccentColor(color: string) => voidSets the accent. Throws InvalidColorError on a non-hex value.
isDarkbooleanWhether dark mode is active.
setDark(dark: boolean) => voidSets dark mode and persists the choice to localStorage.

API reference

ThemeProvider

accentColorstring | null

Controlled accent colour. When provided (including `null`), the provider operates in controlled mode: this value overrides internal state 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` (useful for async data: `collection?.themeColour ?? null`). Without a `defaultAccentColor`, `null` falls back to the parent provider's accent when nested, or to Oztix blue at the root.

defaultAccentColorstring

Initial accent colour when uncontrolled, and the fallback for a `null` or invalid `accentColor`. Defaults to the parent provider's accent when nested, or to Oztix blue at the root.

Defaults to #0191eb.

defaultDarkboolean

Initial dark mode state when no stored preference exists (default: false)

Defaults to false.

followSystemboolean

Respect prefers-color-scheme when no explicit user choice is stored (default: false)

Defaults to false.

Previous page← ShapeNext pageDate and time →