Theming
How themes are authored, how applyTheme injects them, and the rules that keep light/dark coexisting.
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.
{
"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"
}
}
}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>andcolors.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 (outlineLabel→outline-label). The walker emits--<name>-<kebab(prop)>.
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.
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).
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.
<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.
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.
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.
{
"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.
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.
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.
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.
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.
// 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:
// 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.
// 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.