Theme setup
Nexus Appearance derives a full runtime theme from one state object, paints it before React hydrates, and persists the user's choices. Setup has two pieces: a provider and a first-paint script.
Before you start
This page continues from Install, which leaves you
with the token CSS in styles/nexus/ imported from your CSS entry point, the
appearance provider copied into components/appearance/provider/, and
@nexus_ds/core installed.
The Next.js layout below replaces the one Install ends with: same file, but it
becomes async, reads the appearance from a cookie, and swaps
DEFAULT_NEXUS_APPEARANCE for the defaultAppearance and cookie helpers below.
The Vite section is a separate track — take it instead of the Next.js one.
Pick a default appearance
Customize the shipped default by changing state fields. Brand and surface tone are state fields, not factory arguments.
This is what replaces the bare DEFAULT_NEXUS_APPEARANCE that Install passes to
the provider and the script:
// app/appearance-state.ts or src/appearance-state.ts
import {
DEFAULT_NEXUS_APPEARANCE,
type NexusAppearanceState,
} from '@nexus_ds/core';
export const defaultAppearance = {
...DEFAULT_NEXUS_APPEARANCE,
brandColor: '#2563eb',
surfaceTone: 'slate',
} satisfies NexusAppearanceState;Surface tones are stone, neutral, zinc, slate, and gray.
Next.js App Router
Read the cookie on the server, convert its raw string value into a snapshot, put
the inline script in <head>, and render the provider in <body>. The second
argument to createNexusAppearanceSnapshotFromCookie is the SSR fallback when
the cookie is missing or invalid.
// app/layout.tsx
import {
createNexusAppearanceSnapshotFromCookie,
DEFAULT_COOKIE_KEY,
} from '@nexus_ds/core';
import type { Viewport } from 'next';
import { cookies } from 'next/headers';
import { NexusAppearanceProvider } from '@/components/appearance/provider';
import { NexusAppearanceScript } from '@/components/appearance/provider/server';
import { defaultAppearance } from './appearance-state';
import './globals.css';
export const viewport: Viewport = { colorScheme: 'light dark' };
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const snapshot = createNexusAppearanceSnapshotFromCookie(
cookieStore.get(DEFAULT_COOKIE_KEY)?.value,
defaultAppearance
);
return (
<html lang="en" suppressHydrationWarning>
<head>
<NexusAppearanceScript
storageKey="app-appearance"
defaultState={snapshot.state}
/>
</head>
<body className="nx:bg-background nx:text-foreground">
<NexusAppearanceProvider
storageKey="app-appearance"
cookieWriteKey={DEFAULT_COOKIE_KEY}
defaultState={snapshot.state}
>
{children}
</NexusAppearanceProvider>
</body>
</html>
);
}The cookie seeds SSR and first paint. cookieWriteKey writes the active client state
back to a cookie for the next server request; the provider does not read the
cookie again during client hydration. When storageKey is enabled,
localStorage becomes the client-side source of truth after hydration. Use
storageKey={false} when you want the client to hydrate from the server
snapshot/default state without localStorage persistence.
Under a strict CSP, pass a nonce to <NexusAppearanceScript nonce={nonce} />.
Vite
Vite has no server render, so inject the first-paint script at build time with
transformIndexHtml. The bootstrap and provider must use the same storage key.
// vite.config.ts
import {
createNexusAppearanceBootstrapScript,
createNexusAppearanceSnapshotFromState,
} from '@nexus_ds/core';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { defaultAppearance } from './src/appearance-state';
const STORAGE_KEY = 'app-appearance';
const defaultSnapshot =
createNexusAppearanceSnapshotFromState(defaultAppearance);
export default defineConfig({
plugins: [
react(),
{
name: 'nexus-appearance-bootstrap',
transformIndexHtml(html) {
const script = createNexusAppearanceBootstrapScript({
storageKey: STORAGE_KEY,
defaultSnapshot,
});
return html.replace('</head>', `<script>${script}</script></head>`);
},
},
],
});// src/main.tsx
import { createRoot } from 'react-dom/client';
import App from './App';
import { defaultAppearance } from './appearance-state';
// the provider folder from Install, under src/ for a Vite app
import { createNexusAppearance } from './components/appearance/provider';
import './index.css';
const { NexusAppearanceProvider } = createNexusAppearance({
storageKey: 'app-appearance',
defaultState: defaultAppearance,
});
createRoot(document.getElementById('root')!).render(
<NexusAppearanceProvider>
<App />
</NexusAppearanceProvider>
);Do not wait for a client effect to apply Appearance. The script needs to run
before the first frame: in SSR <head> for Next, or in transformIndexHtml for
Vite.
Check your setup
Load the app and confirm each of these before moving on:
-
styles/nexus/nexus.cssis imported from your CSS entry point - The provider wraps your tree, and the script runs in
<head> - Reloading in dark mode produces no flash of the light theme1
- Toggling the mode persists across a full page reload —
storageKeydoes this on its own;cookieWriteKeyneeds a server that re-seedsdefaultStatefrom the cookie
Footnotes
-
If the script is already in
<head>and the flash persists, check that it and the provider were given the samestorageKey— the script paints from that key inlocalStorage, so a mismatch means it restores one state and the provider hydrates another. ↩