Remix
Resolve the theme in a loader, paint it before first paint, then hydrate a matching runtime.
@themvora/remixRemixv2.0.0StableSSR:YesZero-flash:Partial1Install
pnpm add @themvora/core @themvora/remix2Set up
import type { ReactNode } from "react";
import type { LinksFunction } from "@remix-run/node";
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import {
ThemeHead,
ThemeProvider,
getThemeShellProps,
} from "@themvora/remix";
import { getInitialThemeState } from "@themvora/remix/server";
import { getBuiltInThemes } from "@themvora/core";
import styles from "./styles.css?url";
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
const themes = getBuiltInThemes();
export async function loader({ request }: { request: Request }) {
return {
initial: await getInitialThemeState(request, { themes, defaultTheme: "light" }),
};
}
export function Layout({ children }: { children: ReactNode }) {
const { initial } = useLoaderData<typeof loader>();
return (
// Remix owns the document, so <html> is written here — and the blocking
// script in <ThemeHead> rewrites it before first paint. getThemeShellProps
// renders the same attributes and CSS variables that script writes, plus
// suppressHydrationWarning for the resolved mode the server cannot know,
// so React hydrates against the DOM it was actually given.
<html lang="en" {...getThemeShellProps(initial)}>
<head>
<meta charSet="utf-8" />
<Meta />
<Links />
<ThemeHead themes={themes} defaultTheme="light" />
</head>
<body>
<ThemeProvider initial={initial} themes={themes} defaultTheme="light">
{children}
</ThemeProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}mode on ThemeHead and initialMode on ThemeProvider decide what a first-time visitor gets — keep them in one constant.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
// The provider in app/root.tsx supplies the runtime, so a route just reads it.
import { useTheme } from "@themvora/remix";
export default function Index() {
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 {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import { getBuiltInThemes } from "@themvora/core";
import { ThemeHead, ThemeProvider, getThemeShellProps } from "@themvora/remix";
import { getInitialThemeState } from "@themvora/remix/server";
const themes = getBuiltInThemes();
// One source of truth for the starting mode: the pre-paint script and the
// runtime resolve it independently, so both have to be told the same thing.
const INITIAL_MODE = "system";
// Resolve the selection server-side so hydration matches exactly.
export async function loader({ request }: { request: Request }) {
return {
initial: await getInitialThemeState(request, {
themes,
defaultTheme: "light",
mode: INITIAL_MODE,
}),
};
}
export function Layout({ children }: { children: ReactNode }) {
const { initial } = useLoaderData<typeof loader>();
return (
// Remix owns the document, so this element is hand-written — and the
// blocking script rewrites it before first paint. getThemeShellProps
// renders the same attributes and CSS variables the script writes, plus
// suppressHydrationWarning for the resolved mode the server cannot know,
// so React hydrates against the DOM it was given. The scrollbar option
// adds the tv-scrollbar class the pre-paint stylesheet is scoped to.
<html lang="en" {...getThemeShellProps(initial, { scrollbar: true })}>
<head>
<meta charSet="utf-8" />
<Meta />
<Links />
<ThemeHead
themes={themes}
defaultTheme="light"
mode={INITIAL_MODE}
scrollbar
/>
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
const { initial } = useLoaderData<typeof loader>();
return (
<ThemeProvider initial={initial} themes={themes} defaultTheme="light">
<Outlet />
</ThemeProvider>
);
}ThemeHead emits the pre-paint script from the server.What's Available
@themvora/remix ships 14 exports in 5 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 Remix examples.
Loader-driven initial theme
Resolve the theme on the server from the request, then hydrate with it.
// app/root.tsx
import { useLoaderData } from "@remix-run/react";
import { ThemeProvider } from "@themvora/remix";
import { getInitialThemeState } from "@themvora/remix/server";
import { getBuiltInThemes } from "@themvora/core";
// getInitialThemeState takes the registry as a required option; the built-in
// set is a valid registry, so no theme file is needed.
const themes = getBuiltInThemes();
export async function loader({ request }: LoaderFunctionArgs) {
return { initial: await getInitialThemeState(request, { themes }) };
}
export default function App() {
const { initial } = useLoaderData();
return (
<ThemeProvider initial={initial}>
<Outlet />
</ThemeProvider>
);
}Blocking script
Emit the blocking head script so there is no theme flash.
import { Links, Scripts } from "@remix-run/react";
import { ThemeHead } from "@themvora/remix";
import { getBuiltInThemes } from "@themvora/core";
// ThemeHead.themes is required — the built-in set is a valid registry.
const themes = getBuiltInThemes();
export function Layout({ children }) {
return (
<html>
<head>
<ThemeHead themes={themes} defaultTheme="light" />
<Links />
</head>
<body>{children}<Scripts /></body>
</html>
);
}Client switcher
All hooks are re-exported from @themvora/remix for client components.
import { useTheme, useThemeHistory } from "@themvora/remix";
export function ThemeControls() {
const { theme, toggleTheme } = useTheme();
const { undo, redo, canUndo } = useThemeHistory();
return (
<div>
<button onClick={toggleTheme}>{theme.name}</button>
<button onClick={undo} disabled={!canUndo}>Undo</button>
<button onClick={redo}>Redo</button>
</div>
);
}Persistence
createRemixThemePersistence keeps the selection in sync with the server.
import { createRemixThemePersistence } from "@themvora/remix";
import { getBuiltInThemes } from "@themvora/core";
// The registry is the first positional argument; the built-in set works.
const themes = getBuiltInThemes();
export const persistence = createRemixThemePersistence(themes, "light", {
key: "theme",
});Smooth theme transitions
Enable CSS transitions on theme changes for a polished user experience.
// app/root.tsx
import { ThemeProvider } from "@themvora/remix";
export default function App() {
return (
<ThemeProvider
defaultTheme="light"
transition={{ enabled: true, duration: 300, easing: "ease-in-out" }}
>
<Outlet />
</ThemeProvider>
);
}The theme catalogue, and a scoped route
One file defines the themes the loader reads; the other scopes a subtree inside a route and puts history controls next to it.
// Imported as `./themes` from app/root.tsx.
import { defineTheme } from "@themvora/core";
export const themes = [
defineTheme({
name: "mint-light",
meta: { family: "mint", mode: "light" },
tokens: {
colors: { primary: "#10b981", background: "#f0fdf9" },
},
}),
];The theme array the root loader imports: the one place the catalogue is defined.
// Used inside the root provider, so it does NOT create a second runtime of its own.
import { ThemeScope, useThemeHistory } from "@themvora/remix";
export function HistoryScope() {
const { undo, redo, canUndo, canRedo } = useThemeHistory();
return (
<div>
<div>
<button onClick={undo} disabled={!canUndo}>Undo</button>
<button onClick={redo} disabled={!canRedo}>Redo</button>
</div>
<ThemeScope theme="plum-dark" transition={{ duration: 300, easing: "ease" }}>
<span>Always plum-dark</span>
</ThemeScope>
</div>
);
}ThemeScope inside a route component, with the history controls next to it.
The complete runnable app behind this guide is examples/apps/remix — listed with the other example apps.