Skip to content

Theme switching at runtime

helixui themes are runtime-switchable. The cascade does the work — no re-render storm, no FOUC if you do the SSR step right.

Browser-only (SPA / Vite)

The simplest pattern. Read once, hydrate.

'use client';
import { useEffect, useState } from 'react';
import { HelixUIDNAProvider } from '@helixui/core';
import { wildtype, PRESETS, type DNA } from '@helixui/dna';
function readSavedDna(): DNA {
if (typeof localStorage === 'undefined') return wildtype();
const raw = localStorage.getItem('helixui:dna');
if (!raw) return wildtype();
try { return JSON.parse(raw); } catch { return wildtype(); }
}
export function ThemeRoot({ children }: { children: React.ReactNode }) {
const [dna, setDna] = useState<DNA>(() => readSavedDna());
useEffect(() => { localStorage.setItem('helixui:dna', JSON.stringify(dna)); }, [dna]);
return (
<HelixUIDNAProvider dna={dna}>
<ThemeContext.Provider value={{ dna, setDna }}>{children}</ThemeContext.Provider>
</HelixUIDNAProvider>
);
}

To switch:

const { setDna } = useTheme();
setDna(PRESETS.noir());

Next.js (App Router) — no flash

The flash you usually see (“page renders light, then switches to dark after JS loads”) happens because the server doesn’t know the user’s preference. Use a cookie + a tiny inline script to set data-theme before React hydrates:

app/layout.tsx
import { cookies } from 'next/headers';
const FLASH_GUARD = `
(() => {
try {
const dna = JSON.parse(document.cookie.replace(/(?:(?:^|.*;\\s*)helixui-dna\\s*=\\s*([^;]*).*$)|^.*$/, '$1') || 'null');
if (dna && dna.genes && dna.genes.lightness) {
document.documentElement.dataset.theme = dna.genes.lightness.variant;
}
} catch {}
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
const dna = JSON.parse(cookies().get('helixui-dna')?.value ?? '"wildtype"');
return (
<html lang="en">
<head>
<script dangerouslySetInnerHTML={{ __html: FLASH_GUARD }} />
</head>
<body>
<ThemeRoot initial={dna}>{children}</ThemeRoot>
</body>
</html>
);
}

The inline script runs before hydration, sets <html data-theme>, and helixui’s CSS picks up the right semantic tokens immediately.

Account-synced themes

When you persist a user’s DNA on the server, the flow is:

  1. Server fetches the user’s DNA from the database.
  2. Server passes it as a prop to <ThemeRoot>.
  3. Client mutations (DNA Lab edits) call your PATCH /api/me/dna endpoint and update local state.
  4. HelixUIDNAProvider re-renders; the cascade updates instantly.
// app/api/me/dna/route.ts (Next.js App Router)
export async function PATCH(req: Request) {
const dna = await req.json();
await db.user.update({ where: { id }, data: { dna } });
return Response.json({ ok: true });
}
// client
const saveDna = async (next: DNA) => {
setDna(next); // optimistic
try {
await fetch('/api/me/dna', { method: 'PATCH', body: JSON.stringify(next) });
} catch {
setDna(previous); // rollback
toast.error('Could not save theme');
}
};

Multiple themes on one page

Wrap subtrees in their own <HelixUIDNAProvider>. The provider compiles the DNA into a scoped CSS-var block; nesting Just Works.

<HelixUIDNAProvider dna={PRESETS.studio()}>
<Dashboard />
<HelixUIDNAProvider dna={PRESETS.noir()}>
<Card>{/* this card is themed noir; everything else is studio */}</Card>
</HelixUIDNAProvider>
</HelixUIDNAProvider>

Useful for: theme previews, in-app design playground, A/B test surfaces.

Performance

  • One DNA → one <style> block ≈ ~80 CSS variable declarations.
  • Switching DNA = re-emit the block. Browsers handle this fast (sub-frame on a modern laptop).
  • No JS recomputation in components; React doesn’t re-render unless your code reads the DNA via useDna().

If you measure layout shifts, profile, and find DNA switching is the bottleneck — open an issue. Pre-1.0 we want every report.

What we don’t do

  • We don’t ship a “light/dark toggle” button. That’s a 5-line snippet and it constrains your IA decisions. We give you lightness: 'light' | 'dark' | 'auto' and let you decide where the toggle lives.
  • We don’t sync to prefers-color-scheme automatically when the user has explicitly chosen a theme. Auto-mode (the default) does; explicit picks win.