Next.js
App Router theming with server-resolved themes, cookie persistence, and zero-flash.
@themvora/nextNext.jsv2.0.0StableSSR:YesZero-flash:Yes1Install
pnpm add @themvora/core @themvora/next2Set up
import type { ReactNode } from "react";
import { ThemeProvider } from "@themvora/next";
import "./globals.css";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<ThemeProvider defaultTheme="light">
{children}
</ThemeProvider>
);
}defaultTheme. Once they pick System, that choice is stored and reused on every later request.3Add a small UI
Design system
ActiveTokens resolve at runtime, so this card follows whichever theme is active.
export function WorkspaceCard() {
return (
<div className="themvora-example-card">
<div className="themvora-example-header">
<h3 className="themvora-example-title">Design system</h3>
<span className="themvora-example-status">Active</span>
</div>
<p className="themvora-example-description">
Tokens resolve at runtime, so this card follows whichever theme is active.
</p>
<p className="themvora-example-meta">Updated 2 minutes ago</p>
<button className="themvora-example-button" type="button">View themes</button>
</div>
);
}4Style it with Themvora tokens
/* Themvora emits its design tokens as CSS custom properties on the root
element, so a stylesheet only has to read them. Nothing here is
theme-specific: these rules render every theme, including ones you add
later. */
html,
body {
background: var(--theme-color-background);
color: var(--theme-color-foreground);
}
.themvora-example-card {
display: grid;
gap: 10px;
max-width: 22rem;
padding: 16px;
border: 1px solid var(--theme-color-border);
border-radius: var(--theme-radius-lg, 12px);
background: var(--theme-color-card);
color: var(--theme-color-cardForeground);
font-family: inherit;
line-height: 1.5;
box-shadow: var(--theme-shadow-sm, 0 1px 2px rgb(0 0 0 / 0.06));
}
.themvora-example-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.themvora-example-title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.themvora-example-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: var(--theme-color-success);
}
.themvora-example-status::before {
content: "";
width: 6px;
height: 6px;
border-radius: 9999px;
background: currentColor;
}
.themvora-example-description {
margin: 0;
font-size: 13px;
color: var(--theme-color-mutedForeground);
}
.themvora-example-meta {
margin: 0;
font-size: 12px;
color: var(--theme-color-mutedForeground);
}
.themvora-example-button {
justify-self: start;
padding: 8px 14px;
border: 0;
/* Presets and generated themes emit --theme-radius-lg and nothing else on
the radius scale, so the smaller step is derived from it rather than
assumed — the same proportion the utility-class mapping uses. */
border-radius: calc(var(--theme-radius-lg, 12px) * 0.8);
background: var(--theme-color-primary);
color: var(--theme-color-primaryForeground);
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.themvora-example-button:hover {
filter: brightness(1.08);
}
.themvora-example-button:focus-visible {
outline: 2px solid var(--theme-color-ring);
outline-offset: 2px;
}.themvora-example-card and its children read Themvora tokens, so switching the active theme repaints them — no re-render, and no per-theme CSS.The `body` rule uses exactly the two foundational colours — `background` and `foreground`. Every component rule reads one of the 21 colours `validateTheme()` requires, so any valid theme emits it. Radius and shadow are optional and carry fallbacks; no typography token is read, so a theme without a typography group still renders correctly.
5Run and choose a theme
"use client";
import { useTheme } from "@themvora/next/client";
export function ThemeSwitcher() {
const { theme, mode, toggleTheme } = useTheme();
return (
<main
style={{
background: "var(--theme-color-background)",
color: "var(--theme-color-foreground)",
minHeight: "100vh",
padding: "2rem",
}}
>
<h1>Themvora</h1>
<p>Current theme: {theme.name} ({mode})</p>
<button
onClick={toggleTheme}
style={{
background: "var(--theme-color-primary)",
color: "var(--theme-color-primaryForeground)",
border: 0,
borderRadius: "0.5rem",
padding: "0.5rem 1rem",
}}
>
Toggle theme
</button>
<section
style={{
background: "var(--theme-color-card)",
border: "1px solid var(--theme-color-border)",
borderRadius: "0.75rem",
marginTop: "1.5rem",
padding: "1rem",
}}
>
<h2>Themed card</h2>
<p style={{ color: "var(--theme-color-mutedForeground)" }}>
Surface, border and text follow the theme.
</p>
</section>
</main>
);
}Theme configuration
import type { ReactNode } from "react";
import { ThemeProvider } from "@themvora/next";
import "./globals.css";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<ThemeProvider defaultTheme="mint-light">
{children}
</ThemeProvider>
);
}app/layout.tsx. Next does not use Vite, so there is no bundler plugin to add: the provider resolves the selection on the server and emits the pre-paint script itself.What's Available
@themvora/next ships 13 exports in 4 categories. Click an export to see what it does and jump to the code that uses it.
Three pages cover this package, and they do different jobs: this index says what exists, the examples page shows each export in code with the problem it solves, and the API reference gives exact signatures and return types.
Use Cases
Short snippets you can drop into an existing app. For the same problems worked through end to end — problem, APIs, expectations, limitations — see the Next.js examples.
SSR provider with zero-flash
The server component resolves the initial theme, renders CSS variables in the HTML, and blocks the flash.
// app/layout.tsx
import { ThemeProvider } from "@themvora/next";
export default function RootLayout({ children }) {
return (
<ThemeProvider
defaultTheme="system"
className="scroll-smooth"
body={{ className: "font-sans" }}
>
{children}
</ThemeProvider>
);
}Client-side switcher
Hooks live behind @themvora/next/client and update cookies + DOM together.
// app/theme-switcher.tsx
"use client";
import { useTheme } from "@themvora/next/client";
export function ThemeSwitcher() {
const { theme, mode, setMode, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
{theme.name} · {mode}
</button>
);
}Scoped theming in a client component
ThemeScope works in any client component, next to server-rendered content.
// app/widgets.tsx
"use client";
import { ThemeScope } from "@themvora/next/client";
import type { ThemeTransitionOptions } from "@themvora/core";
const transition: ThemeTransitionOptions = { duration: 300, easing: "ease" };
export function Widget() {
return (
<ThemeScope theme="plum-dark" transition={transition}>
<div className="rounded-xl p-4">Always plum-dark here</div>
</ThemeScope>
);
}Runtime access + lifecycle events
Reach the underlying runtime from a client component, and subscribe to lifecycle events such as beforeThemeChange.
// app/runtime-demo.tsx
"use client";
import { useEffect } from "react";
import { useThemeRuntime, useThemeLifecycle } from "@themvora/next/client";
export function RuntimeDemo() {
const runtime = useThemeRuntime();
const { on } = useThemeLifecycle();
useEffect(() => {
// Fires for every theme change, including one arriving from another tab.
const off = on("beforeThemeChange", (e) => console.log(e.next.name));
return off;
}, [on]);
return (
<button onClick={() => runtime.update({ radius: { sm: "8px", md: "12px" } })}>
Soften corners
</button>
);
}Sunrise / sunset scheduling
Switch between a light and a dark theme at each visitor's local sunrise and sunset. Coordinates are auto-detected from the browser timezone — pin them with latitude/longitude or timeZone when you need to.
// app/layout.tsx — no theme definitions required
import type { ReactNode } from "react";
import { ThemeProvider } from "@themvora/next";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
// No `themes` prop: the built-in neutral light/dark pair is used.
// With your own themes, name the pair instead:
// scheduled={{ lightTheme: "mint-light", darkTheme: "mint-dark" }}
<ThemeProvider defaultTheme="light" scheduled={{}}>
{children}
</ThemeProvider>
);
}
// app/schedule-status.tsx
"use client";
import { useThemeSchedule } from "@themvora/next/client";
export function ScheduleStatus() {
const schedule = useThemeSchedule();
if (!schedule) return null;
return (
<button
onClick={() => (schedule.enabled ? schedule.disable() : schedule.enable())}
>
{schedule.status}
{schedule.sunrise ? " · sunrise " + schedule.sunrise.toLocaleTimeString() : ""}
{schedule.sunset ? " · sunset " + schedule.sunset.toLocaleTimeString() : ""}
</button>
);
}Multi-window sync
Sync is on by default — there is no API to call. Every runtime installs a BroadcastChannel adapter on the "theme-selection" channel, so switching the theme in one tab updates every other open tab. This component renders the shared selection, so you can watch it change from a second tab.
// app/sync-status.tsx
"use client";
import { useTheme } from "@themvora/next/client";
export function SyncStatus() {
const { theme, family, mode } = useTheme();
// Open the site in two tabs and switch the theme in one — this updates in
// both, because the runtime broadcasts the selection over BroadcastChannel.
return (
<p data-theme={theme.name}>
{family} · {mode}
</p>
);
}Smooth theme transitions
Enable CSS transitions on theme changes for a polished user experience.
// app/layout.tsx
import { ThemeProvider } from "@themvora/next";
export default function RootLayout({ children }) {
return (
<ThemeProvider
defaultTheme="light"
transition={{
enabled: true,
duration: 300,
easing: "ease-in-out",
}}
>
{children}
</ThemeProvider>
);
}Scoped theming inside a route
The root layout sets the app-wide theme from the cookies. A client component can scope a different one for part of the tree without touching that selection.
// Scoped theming + history controls
"use client";
import { ThemeScope, useThemeHistory } from "@themvora/next/client";
export function Dashboard() {
const { undo, redo, canUndo, canRedo } = useThemeHistory();
return (
<div>
<div>
<button onClick={undo} disabled={!canUndo}>Undo</button>
<button onClick={redo} disabled={!canRedo}>Redo</button>
</div>
{/* Pinned to plum-dark, whatever the rest of the page is using. */}
<ThemeScope theme="plum-dark">
<p>This subtree is always plum-dark.</p>
</ThemeScope>
</div>
);
}A dashboard segment pinned to one theme, alongside undo and redo for the page's selection.
The complete runnable app behind this guide is examples/apps/next — listed with the other example apps.