Library

Theming

How themes are authored, how applyTheme injects them, and the rules that keep light/dark coexisting.

01AnatomyJSON shape

A theme is plain data

Every Clay theme is a JSON file with a stable shape: identification fields plus optional sections for each token category. The smallest valid theme provides only id, name, description, and accentSwatches. Real themes opt into colors, geometry, borders, motion, focus, and per-component overrides as they need.

jsonpresets/brutalist.json28 lines
{
"id": "brutalist",
"name": "Brutalist",
"description": "Sharp corners, thick borders, monospace UI, no shadows.",
"accentSwatches": ["#000000", "#ffd400"],

"colors": {
  "light": { "background": "#fafafa", "foreground": "#0a0a0a" },
  "dark":  { "background": "#0a0a0a", "foreground": "#fafafa" }
},

"geometry":  { "radius": "0px", "fontSans": "JetBrains Mono, monospace" },
"borders":   { "width": "2px", "style": "solid" },
"motion":    { "duration": "0ms" },
"focus":     { "width": "3px", "offset": "3px" },

"components": {
  "button": {
    "letterSpacing": "0.08em",
    "textTransform": "uppercase",
    "borderWidth": "2px"
  },
  "card": {
    "shadow": "none",
    "borderWidth": "2px"
  }
}
}
02Path → VarNaming convention

Path-to-variable mapping

Every section in a ThemeConfig maps onto CSS custom properties via a small set of rules. The /tokens reference shows the exact path → var mapping for each entry, but the rules are:

  • colors.light.<name> and colors.dark.<name>, <name> is a kebab-case CSS var. Both --<name> and --color-<name> are emitted.
  • geometry.<prop>, looks up a static map (fontSans--font-sans, radius--radius, etc.).
  • borders.<prop>, --border-width, --border-style.
  • motion.<prop>, --motion-duration, --motion-easing.
  • focus.<prop>, note the rename: focus.width--ring-width, focus.offset--ring-offset.
  • components.<name>.<prop>, <name> is kebab-case (switch-thumb), <prop> is camelCase (outlineLabeloutline-label). The walker emits --<name>-<kebab(prop)>.
03RuntimeapplyTheme

Apply at runtime

applyTheme(theme) injects a single <style id="clay-theme"> tag into document.head containing both the :root light defaults and a :is(.dark, [data-mode="dark"]):root block for dark overrides. Toggling data-mode afterwards costs nothing, the dark block activates via CSS, no JS re-run. SSR-safe: returns a no-op cleanup when document is undefined.

tsxapply-a-theme.tsx10 lines
import { applyTheme, brutalist } from '@brika/clay/themes';

// Document-wide. Returns a cleanup function that removes the style tag.
const cleanup = applyTheme(brutalist);

// Toggle dark mode without re-applying:
document.documentElement.setAttribute('data-mode', 'dark');

// Restore the stylesheet defaults:
cleanup();

For a scoped preview without affecting the rest of the document, use themeToCssVars(theme, mode), it returns a React style-prop object you can spread on a single subtree.

The cleanest way to scope a theme is the <ThemeScope> component, no hand-rolled <div style={...}>, and by default no extra layout box (it uses display: contents so the wrapper never affects the layout tree).

tsxscoped-preview.tsx6 lines
import { ocean, ThemeScope } from '@brika/clay/themes';

<ThemeScope theme={ocean} mode="light">
<Button>Ocean button</Button>
<Card>Ocean card</Card>
</ThemeScope>

For zero-DOM theming, when you don’t even want the display: contents wrapper, pass asChild and a single child element. The theme’s CSS variables are merged onto the child via Radix Slot, so the rendered tree is exactly the child you wrote.

tsxscoped-aschild.tsx3 lines
<ThemeScope theme={ocean} asChild>
<article className="prose"></article>
</ThemeScope>

The same effect, written by hand: spread themeToCssVars(theme, mode) onto any element you control. Useful when you need finer control over the wrapper element.

tsxscoped-manual.tsx5 lines
import { themeToCssVars, terminal } from '@brika/clay/themes';

<div style={themeToCssVars(terminal, 'dark')}>
<Button>Looks Terminal-y inside this div only.</Button>
</div>

Scope leak resistance. Both <ThemeScope> and themeToCssVars produce a complete CSS-variable map, every registry default plus the theme’s overrides. So a scoped preview never inherits a token from a globally-applied theme, even if applyTheme redefined the same token at :root. This is what lets the gallery on /themes show 16 cards side-by-side, each isolated in its own theme.

04GlassTranslucency + blur

Glass & tinted-glass effects

Glass surfaces combine three things: a translucent fill colour, backdrop-filter: blur(), and a saturated border. Clay exposes a --<component>-backdrop-blur token on every floating surface (card, dialog, popover, sheet, dropdown, select content). The glass.tint scalar layers a soft colour wash above the blur for the iOS-style “tinted glass” look. The shipped glass theme demonstrates the pattern.

jsonmy-glass.json21 lines
{
"id": "my-glass",
"name": "My Glass",
"description": "...",
"accentSwatches": ["#7dd3fc"],

"colors": {
  "light": {
    "card":   "rgba(255, 255, 255, 0.55)",
    "border": "rgba(255, 255, 255, 0.6)"
  }
},

"geometry": { "radius": "1.25rem", "backdropBlur": "16px" },

"components": {
  "card":   { "backdropBlur": "20px" },
  "dialog": { "backdropBlur": "24px" },
  "popover":{ "backdropBlur": "16px" }
}
}

Set colors.light.card (and popover, secondary, …) to an rgba(...) or oklch(... / 0.55) value to make the surface translucent. The matching --<component>-backdrop-blur does the frosting. Border tokens stay on the same components, so a 1px white rgba(255, 255, 255, 0.6) rim reads as the seam in the glass.

05SSRNo FOUC

Server-side rendering

Render the same stylesheet during HTML generation with renderThemeStyleSheet(theme) and embed it in a <style> tag in the document <head>. The client then mounts and calls applyTheme, which finds the existing tag and updates its content idempotently, no flash of unstyled content.

06BundlePer-theme vs. registry

Loading only what you need

@brika/clay/themes re-exports each preset by name. Importing one pulls just that JSON into your bundle, every other preset is dropped by tree-shaking:

import { ocean, applyTheme } from '@brika/clay/themes';
applyTheme(ocean);

Pickers, galleries, and SSR pre-rendering need the full ordered list. That lives behind a separate, opt-in entry so a one-theme app never pays for the other seventeen JSON files:

import { builtInThemes, builtInThemesById } from '@brika/clay/themes/registry';

Same ThemeConfig shape, same narrative order, just isolated to a chunk you only load when you actually need to enumerate themes.

07PersistApp-side responsibility

Remembering the choice

Clay never touches localStorage, cookies, or any other persistence layer. Storage policy belongs to the app, your auth context, your user-prefs API, your SSR cookies, your privacy stance. Clay just exposes applyTheme and builtInThemesById and leaves the rest to you.

Below is the canonical React pattern: a provider that reads the saved id on mount, calls applyTheme on every change, and writes back to your storage of choice. Drop it in once at the top of your tree and useClayTheme() is available everywhere underneath.

tsxtheme-provider.tsx64 lines
import { applyTheme } from '@brika/clay/themes';
import { builtInThemesById } from '@brika/clay/themes/registry';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';

const STORAGE_KEY = 'app-theme';
const DEFAULT_ID = 'default';

interface ClayThemeContextValue {
themeId: string;
setThemeId: (id: string) => void;
}

const ClayThemeContext = createContext<ClayThemeContextValue | null>(null);

function readStoredId(): string {
if (typeof localStorage === 'undefined') {
  return DEFAULT_ID;
}
try {
  const stored = localStorage.getItem(STORAGE_KEY);
  return stored && builtInThemesById[stored] ? stored : DEFAULT_ID;
} catch {
  return DEFAULT_ID;
}
}

export function ClayThemeProvider({ children }: { children: ReactNode }) {
const [themeId, setThemeIdState] = useState<string>(DEFAULT_ID);

// Read once on mount, keeps SSR output deterministic.
useEffect(() => {
  setThemeIdState(readStoredId());
}, []);

// Apply (and re-apply on change). applyTheme returns a cleanup that
// removes the <style> tag, so swapping themes never leaves residue.
useEffect(() => {
  const theme = builtInThemesById[themeId];
  if (!theme) return;
  return applyTheme(theme);
}, [themeId]);

const setThemeId = useCallback((id: string) => {
  setThemeIdState(id);
  try {
    localStorage.setItem(STORAGE_KEY, id);
  } catch {
    /* storage unavailable, in-memory only */
  }
}, []);

return (
  <ClayThemeContext.Provider value={{ themeId, setThemeId }}>
    {children}
  </ClayThemeContext.Provider>
);
}

export function useClayTheme() {
const ctx = useContext(ClayThemeContext);
if (!ctx) throw new Error('useClayTheme must be used inside ClayThemeProvider');
return ctx;
}

Avoiding the flash on first paint

The provider above resolves the saved id inside useEffect, which runs after hydration. A returning visitor whose saved theme is not default therefore sees Clay’s default for one frame before the real theme paints. There’s no single fix, the right strategy depends on whether your host can read the request before responding. Three options, in order of preference:

Option A, read it on the server (recommended)

If the visitor’s chosen theme is in a cookie (or a session, or a user-prefs lookup), the server already knows the answer at HTML-gen time. Render the matching stylesheet inline and the page paints correctly on the first frame, no client script, no map of every theme’s CSS, no flash possible.

The trick that makes this clean with Clay is that applyTheme reuses the existing <style id="clay-theme"> tag instead of appending a second one. So a server-rendered tag with the right content is invisibly adopted on hydration: when the provider’s applyTheme call runs, it finds the tag, rewrites it with byte- identical CSS, and the browser does no work.

tsxapp/layout.tsx23 lines
// Next.js App Router, equivalents exist in Remix (loaders),
// Astro (Astro.cookies), SvelteKit (load), Nuxt (useRequestEvent), etc.
import { cookies } from 'next/headers';
import { renderThemeStyleSheet } from '@brika/clay/themes';
import { builtInThemesById } from '@brika/clay/themes/registry';
import { ClayThemeProvider } from './theme-provider';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
const stored = (await cookies()).get('app-theme')?.value;
const theme = (stored && builtInThemesById[stored]) ?? builtInThemesById.default;
const css = renderThemeStyleSheet(theme);

return (
  <html lang="en" data-mode="light">
    <head>
      <style id="clay-theme" dangerouslySetInnerHTML={{ __html: css }} />
    </head>
    <body>
      <ClayThemeProvider initialThemeId={theme.id}>{children}</ClayThemeProvider>
    </body>
  </html>
);
}

For this to be flash-free, the provider must start with the same id the server picked, pass initialThemeId through and seed useState(initialThemeId) instead of reading storage in useEffect. Switch the persistence write from localStorage to a cookie so the next request is also server-correct:

tsxtheme-provider.tsx23 lines
// Cookie-SSR variant of the provider above.
export function ClayThemeProvider({
initialThemeId,
children,
}: { initialThemeId: string; children: ReactNode }) {
const [themeId, setThemeIdState] = useState(initialThemeId);

useEffect(() => {
  const theme = builtInThemesById[themeId];
  if (theme) return applyTheme(theme);
}, [themeId]);

const setThemeId = useCallback((id: string) => {
  setThemeIdState(id);
  document.cookie = `app-theme=${id}; path=/; max-age=31536000; samesite=lax`;
}, []);

return (
  <ClayThemeContext.Provider value={{ themeId, setThemeId }}>
    {children}
  </ClayThemeContext.Provider>
);
}

Option B, accept it

The flash is one frame, on returning visitors only, and only when they picked a non-default theme. For a lot of apps that’s a fine trade for zero extra machinery, no SSR coupling, no extra script, no per-theme CSS shipped just to bridge a frame. Ship the canonical provider above and stop.

Worth saying out loud: if your “theming” is really just light/dark plus an accent, you don’t need this section at all. A 6-line inline script that sets data-mode from a cookie or prefers-color-scheme is the standard pattern, and applyTheme is untouched. Multi-theme persistence is the case that makes the boot script tempting.

Option C, inline boot script (static hosting only)

When you can’t run code on the request, pure SPA on a CDN, no edge worker, theme id only in localStorage, the only way to avoid the flash is to inline a synchronous script in <head> that picks the right CSS before paint. This is what next-themes and similar libraries do; it works, but it’s a workaround for the lack of server-side knowledge, not the destination.

Pre-render each theme’s stylesheet at build time, ship them as a JSON map, and have the script choose one and append a <style id="clay-theme"> tag. On hydration the provider’s applyTheme adopts that tag.

tsxboot-script.tsx24 lines
// 1. Build time: serialise every theme's CSS into a JSON map.
import { renderThemeStyleSheet } from '@brika/clay/themes';
import { builtInThemes } from '@brika/clay/themes/registry';

const THEME_CSS_JSON = JSON.stringify(
Object.fromEntries(builtInThemes.map((t) => [t.id, renderThemeStyleSheet(t)]))
);

// 2. Inline this in <head> before any stylesheet link.
const boot = `
(() => {
  try {
    const map = JSON.parse(${JSON.stringify(THEME_CSS_JSON)});
    const id = localStorage.getItem('app-theme') || 'default';
    const css = map[id];
    if (!css) return;
    document.documentElement.dataset.theme = id;
    const tag = document.createElement('style');
    tag.id = 'clay-theme';
    tag.textContent = css;
    document.head.appendChild(tag);
  } catch {}
})();
`;

The cost is the JSON payload, every theme’s full CSS shipped in the HTML. Shrink it by inlining only the themes the user actually reaches: the default, plus their last selection (localStorage), plus whatever a picker can lazy-load on demand.