Remix examples
Each export in @themvora/remix, with the problem it solves and the code that solves it.
@themvora/remixRemixv2.0.0StableRemix is the integration where the app, not the library, writes the document. That changes the shape of the setup: the loader resolves the theme from the request, the root route renders <html> with the attributes getThemeShellProps() derives, and ThemeHead puts the blocking script in <head>.
Every piece has to agree, which is why the API is small and opinionated. ThemeProvider then hydrates against the loader's initial value, so the first client render equals the server's markup and the selection is mirrored back to cookies.
1The root route owns the runtime
getInitialThemeState(), <ThemeHead> in the document head, <html> spread with getThemeShellProps(), and <ThemeProvider> around <Outlet />.import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
import { ThemeProvider } from "@themvora/remix";
import { ThemeHead } from "@themvora/remix";
import { getInitialThemeState } from "@themvora/remix/server";
import { getThemeShellProps } from "@themvora/remix";
import { getBuiltInThemes } from "@themvora/core";
const themes = getBuiltInThemes();
export async function loader({ request }: LoaderFunctionArgs) {
const initial = await getInitialThemeState({ request, themes, defaultTheme: "mint-light" });
return { initial };
}
export default function App({ loaderData }: { loaderData: Awaited<ReturnType<typeof loader>> }) {
const { initial } = loaderData;
return (
<html lang="en" {...getThemeShellProps(initial)}>
<head>
<Meta />
<Links />
<ThemeHead themes={themes} defaultTheme="mint-light" />
</head>
<body>
<ThemeProvider initial={initial} themes={themes} defaultTheme="mint-light">
<Outlet />
</ThemeProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}getThemeShellProps() exists so you never hand-copy the seven attributes the blocking script writes — the script and the server markup come from one function.
2Loaders, route scopes, and one provider
Wire the root route once, correctly
The pieces are individually documented but only work together: a loader, a head script, shell attributes and a provider that all have to agree on the same resolution.
getInitialThemeState()ThemeHeadgetThemeShellProps()ThemeProviderimport type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
import { ThemeHead, ThemeProvider, getThemeShellProps } from "@themvora/remix";
import { getInitialThemeState } from "@themvora/remix/server";
import { getBuiltInThemes } from "@themvora/core";
const themes = getBuiltInThemes();
export async function loader({ request }: LoaderFunctionArgs) {
const initial = await getInitialThemeState({ request, themes, defaultTheme: "mint-light" });
return { initial };
}
export default function App({ loaderData }: { loaderData: Awaited<ReturnType<typeof loader>> }) {
const { initial } = loaderData;
return (
<html lang="en" {...getThemeShellProps(initial)}>
<head>
<Meta />
<Links />
<ThemeHead themes={themes} defaultTheme="mint-light" mode="system" />
</head>
<body>
<ThemeProvider initial={initial} themes={themes} defaultTheme="mint-light">
<Outlet />
</ThemeProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}Each piece consumes the same initial value, so there is exactly one resolution per request. That is what removes both the flash and the hydration mismatch. Reloading with any persisted selection paints that selection immediately, and no component reports a hydration mismatch.
Watch out: ThemeHead's mode and ThemeProvider's initialMode must match. Leaving them out of step produces a wrong-theme flash, not a crash.
A mode toggle that reports the truth
The toggle's icon says light while the page is dark, because the selection is system.
useTheme()useSetThemeMode()import { useSetThemeMode, useTheme } from "@themvora/remix";
export function ModeToggle() {
// Both readings come off `useTheme()`: @themvora/remix re-exports the React
// hook set, and the separate `useEffectiveThemeMode()` hook is not one of the
// names it re-exports.
const { mode, effectiveMode } = useTheme();
const setMode = useSetThemeMode();
const next = effectiveMode === "dark" ? "light" : "dark";
return (
<button
onClick={() => setMode(next)}
aria-pressed={effectiveMode === "dark"}
aria-label={`Switch to ${next} mode`}
>
{effectiveMode === "dark" ? "🌙 Dark" : "☀️ Light"}
{mode === "system" ? " (auto)" : null}
</button>
);
}effectiveMode is the applied appearance and moves with the page; mode is the standing request. A label needs the first. Icon and label always match the page; a click pins an explicit mode so a system visitor stops following the OS.
Preview families without switching to them
A gallery should show each family before the visitor commits, without repainting the whole page per preview.
ThemeScopeuseProvidedThemes()useSetThemeFamily()import {
ThemeScope,
useProvidedThemes,
useSetThemeFamily,
} from "@themvora/remix";
export default function Themes() {
const families = useProvidedThemes();
const setFamily = useSetThemeFamily();
return (
<div>
{families.map((f) => (
<button key={f.family} onClick={() => setFamily(f.family)}>
<ThemeScope family={f.family}>
<strong>{f.family}</strong>
<p>The quick brown fox</p>
</ThemeScope>
</button>
))}
</div>
);
}ThemeScope writes the scoped variables onto its own wrapper. Inside a route it sits under the root provider, so a preview reads the same registry the page does — without the global selection moving. Each card renders in its own family; clicking one commits the choice through the setter.
3Server exports, hooks, and scopes
Server
These run in the loader and in the document render. Together they decide what the first byte looks like.
getInitialThemeState()
Reads the theme-mode and theme-family cookies off the request and resolves the initial theme state against the registry. Imported from @themvora/remix/server.
In the root loader, on every request. This is the value the rest of the setup is built around.
import type { LoaderFunctionArgs } from "@remix-run/node";
import { getInitialThemeState } from "@themvora/remix/server";
import { getBuiltInThemes } from "@themvora/core";
const themes = getBuiltInThemes();
export async function loader({ request }: LoaderFunctionArgs) {
const initial = await getInitialThemeState({
request,
themes,
defaultTheme: "mint-light",
});
return { initial };
}Returns the resolved theme and selection. Passing it to ThemeProvider is what makes hydration exact rather than approximate.
ThemeHead
Emits the blocking bootstrap script in the document <head> so the page paints already themed, plus the prefers-color-scheme dark fallback for system mode.
Once, in the root route's <head>. Without it the server markup is themed but a system visitor with a dark OS gets a correction frame.
import { ThemeHead } from "@themvora/remix";
<head>
<Meta />
<Links />
<ThemeHead themes={themes} defaultTheme="mint-light" mode="system" />
</head>The persisted selection is applied before first paint. mode on ThemeHead must match initialMode on ThemeProvider, or the script paints one theme and the runtime corrects it.
getThemeShellProps()
Produces the <html> attributes and CSS variables the blocking script writes, plus suppressHydrationWarning for the resolved mode the server cannot know.
Always, on the root <html>. Remix owns the document, so the shell has to be written by the app — this is what keeps it in agreement with the script instead of hand-copying seven attributes.
import { getThemeShellProps } from "@themvora/remix";
// Attributes, variables and suppressHydrationWarning in one spread.
<html lang="en" {...getThemeShellProps(initial)}>
// With the overlay scrollbar opted in:
<html lang="en" {...getThemeShellProps(initial, { scrollbar: true })}>The server's <html> carries exactly what the script would write, so there is nothing to correct. { scrollbar: true } adds the tv-scrollbar class the overlay needs from the first paint.
SSR-first resolution
The full path: request → loader → resolve initial state → HTML document → browser hydration. The server resolves the theme, so the first paint already matches the hydrated client.
This is the architecture rather than a call you make. Understanding it is what tells you why initial must be threaded through every step.
const initial = await getInitialThemeState({ request, themes, defaultTheme: "mint-light" });
<html {...getThemeShellProps(initial)}>
<ThemeHead themes={themes} defaultTheme="mint-light" />
<ThemeProvider initial={initial} themes={themes} defaultTheme="mint-light">No flash and no hydration mismatch, provided the loader's value reaches ThemeProvider and getThemeShellProps().
Client
The client half hydrates against the loader's result. The hooks come from @themvora/remix, which re-exports them so a route never has to reach into a second package.
ThemeProvider
Hydrates against the loader-resolved initial state and mirrors the selection back to cookies on change. Omitting initial is flash-free but not hydration-clean, because the server rendered the fallback.
Once, in the root route around <Outlet />. Nested routes and layouts inherit it.
import { ThemeProvider } from "@themvora/remix";
<ThemeProvider initial={initial} themes={themes} defaultTheme="mint-light">
<Outlet />
</ThemeProvider>The first client render equals the server's markup. Selection changes persist to the cookies the loader reads on the next navigation.
createRemixThemePersistence()
Builds the persistence adapter ThemeProvider uses: localStorage plus the theme-mode, theme-family and theme-fingerprint cookies the loader reads on the next request.
You are constructing the runtime yourself — a shared runtime, a test, or a provider you wrap. The provider wires this for you otherwise.
import { createThemeRuntime, getBuiltInThemes } from "@themvora/core";
import { createRemixThemePersistence } from "@themvora/remix";
const themes = getBuiltInThemes();
export const runtime = createThemeRuntime({
themes,
defaultTheme: "mint-light",
persistence: createRemixThemePersistence({ themes }),
});Selection changes survive reloads and are visible to the loader. The fingerprint rejects a cookie whose theme no longer exists.
Full hook set + ThemeScope
The whole hook set — useTheme(), useSetThemeMode(), useThemeRuntime(), history, batch, schedule — plus scoped subtrees. All of it is re-exported from @themvora/react through this package, so a route imports from one place and gets the version this integration is built against.
Any route component that reads or changes theme state, and any route that needs a scoped region.
import { useTheme, useToggleTheme } from "@themvora/remix";
export default function Index() {
// `useTheme()` carries both readings: `mode` is what the visitor asked for,
// `effectiveMode` is what is actually painted.
const { theme, mode, effectiveMode } = useTheme();
const toggleTheme = useToggleTheme();
return (
<main>
<h1>{theme.name}</h1>
<p>
Requested {mode} · showing {effectiveMode}
</p>
<button onClick={toggleTheme}>Toggle</button>
</main>
);
}Reads and writes behave as they do elsewhere in the app; what sits underneath is the Remix persistence contract, so a selection survives the next navigation.
Intentional React dependency
@themvora/remix depends on @themvora/core and @themvora/react directly. Remix is a React framework, so there is no framework-neutral layer to preserve — and no other framework or adapter package is pulled in.
Relevant when auditing your dependency tree or deciding where to import from. It explains why this package's surface is the React one.
{
"dependencies": {
"@themvora/core": "workspace:*",
"@themvora/react": "workspace:*"
}
}One React implementation, not a wrapper around a wrapper. The hooks are the same code the React integration ships.
Relatedremix-hooks
Routing
Two placement rules that follow from Remix's nested routing: where the provider goes, and where a scope goes.
Provider lives in the root
ThemeProvider is mounted in app/root.tsx; nested routes and layouts inherit it through <Outlet />.
Always. Mounting a provider inside a route gives that route a second runtime, so the header and the page can disagree.
<body>
<ThemeProvider initial={initial} themes={themes} defaultTheme="mint-light">
<Outlet />
</ThemeProvider>
<ScrollRestoration />
<Scripts />
</body>One runtime for the whole app, and one place that decides persistence.
Route-level scopes
Wrap a route subtree in ThemeScope to pin it to a theme without changing the global selection.
A marketing or pricing route that must render in a fixed palette while the app keeps the visitor's choice.
import { ThemeScope } from "@themvora/remix";
export default function Pricing() {
return (
<ThemeScope theme="cocoa-light">
<h1>Pricing</h1>
<PricingTable />
</ThemeScope>
);
}Only the wrapped subtree repaints. The selection is neither read for the scope nor changed by it.
Relatedremix-rootremix-hooks
Transition
Configured on the root provider. Nested routes inherit the runtime, so there is one transition setting for the whole app.
transition
Documents transition prop
Enables and configures the cross-fade applied when the theme changes — duration, easing, preset, animated properties.
Leave the default on; tune it when a large surface feels slow, or disable it for an app that must repaint instantly.
<ThemeProvider
initial={initial}
themes={themes}
defaultTheme="mint-light"
transition={{ enabled: true, duration: 250, easing: "ease-in-out" }}
>
<Outlet />
</ThemeProvider>Theme changes animate; the DOM writes still happen in one flush.
runtime.store.set(theme, { suppressTransition: true })
Applies one theme change immediately, skipping the configured animation for that update only.
A print path, a screenshot, or a programmatic reset where animating would look like a glitch.
import { useThemeRuntime } from "@themvora/remix";
export default function Settings() {
const runtime = useThemeRuntime();
function reset() {
const light = runtime.themes.find((theme) => theme.name === "mint-light");
if (!light) return;
runtime.store.set(light, { suppressTransition: true });
}
return <button onClick={reset}>Reset appearance</button>;
}That change is instant; later changes animate again.
Relatedremix-transitionremix-hooks
Scrollbar
The overlay needs two things from the server: a class on <html> and a pre-paint stylesheet. Miss either and the native bar is painted first.
scrollbar
Documents scrollbar prop
Passing scrollbar to ThemeHead hides the native scrollbar from the very first paint and hands over to Themvora's overlay engine once the runtime hydrates.
A custom scrollbar is part of the design. Without the pre-paint half the page paints a native bar and then replaces it.
<head>
<ThemeHead themes={themes} defaultTheme="mint-light" scrollbar />
</head>No native-bar flash. The overlay follows runtime theme changes.
Import the pre-paint styles
The overlay needs import "@themvora/core/scrollbar.css" plus the tv-scrollbar class on <html>. getThemeShellProps(initial, { scrollbar: true }) renders that class.
Always, together with the scrollbar prop. Both halves are required — the class without the stylesheet hides nothing, and the stylesheet without the class has no effect.
import "@themvora/core/scrollbar.css";
<html lang="en" {...getThemeShellProps(initial, { scrollbar: true })}>The native bar is hidden before the first paint instead of after hydration.
4The complete root route
A complete root route
The wiring recipe with the optional pieces switched on: transitions, the overlay scrollbar and its pre-paint stylesheet.
import "@themvora/core/scrollbar.css";
import { ThemeHead, ThemeProvider, getThemeShellProps } from "@themvora/remix";
import { getInitialThemeState } from "@themvora/remix/server";
import { getBuiltInThemes } from "@themvora/core";
const themes = getBuiltInThemes();
export async function loader({ request }: LoaderFunctionArgs) {
const initial = await getInitialThemeState({ request, themes, defaultTheme: "mint-light" });
return { initial };
}
export default function App({ loaderData }: { loaderData: Awaited<ReturnType<typeof loader>> }) {
const { initial } = loaderData;
return (
<html lang="en" {...getThemeShellProps(initial, { scrollbar: true })}>
<head>
<Meta />
<Links />
<ThemeHead themes={themes} defaultTheme="mint-light" scrollbar />
</head>
<body>
<ThemeProvider
initial={initial}
themes={themes}
defaultTheme="mint-light"
transition={{ enabled: true, duration: 250 }}
>
<Outlet />
</ThemeProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}Not available in this integration
These exports exist in other integrations but not in @themvora/remix. Listing them is more useful than inventing an example: if you need one of them here, the workaround is named.
Remix owns the document, so this package cannot render <html> for you. The equivalent is the loader + ThemeHead + getThemeShellProps() combination above.
Not exported here, and not usable: an SSR app must hydrate the server's markup rather than mount a fresh root over it.