Scoped Theme
@themvora/core · ThemeScope
Most theming is global. Scoped theming is not: it lets you apply a whole theme — palette, radius, typography — to a subtree, turning it into a sandboxed island that ignores the global theme. Great for embedded widgets, preview panes, marketing sections, and multi-brand surfaces inside one app.
Prerequisites
Themvora provider mounted
Custom themes defined
Framework adapter installed
Pick your framework
1ThemeScope — pick your framework
The same scoping primitive in every adapter. Select a framework to see the exact implementation.
// src/App.tsx — the provider is mounted in src/main.tsx.
import { ThemeScope } from "@themvora/react";
import type { ThemeTransitionOptions } from "@themvora/core";
const scopeTransition: ThemeTransitionOptions = { duration: 300, easing: "cubic-bezier(0.4, 0, 0.2, 1)" };
export function App() {
return (
<ThemeScope theme="plum-dark" transition={scopeTransition}>
<Sidebar />
</ThemeScope>
);
}How a scope selects its theme |Pass an exact theme name like
plum-dark, a family name — the scope resolves the family's theme for the current mode — or split them into family + mode props. Omit both to mirror the global selection inside a fresh boundary. Every prop is reactive: when themechanges, the scope re-resolves and animates without a remount. An unregistered name falls back to the first theme.2Imperative scoping
When you don't control JSX or markup — e.g. a video player or legacy DOM — scope a raw element directly.
import {
createThemeRuntime,
createScopedThemeBinding,
getBuiltInThemes,
} from "@themvora/core";
// createScopedThemeBinding takes the registry positionally; the built-in set
// is a valid registry, so no theme file is needed. createThemeRuntime falls
// back to it on its own.
const themes = getBuiltInThemes();
const runtime = createThemeRuntime({ defaultTheme: "light" });
const binding = createScopedThemeBinding(themes, el, "plum-dark");
binding.destroy();"use client";
import { useRef } from "react";
import { useScopedTheme } from "@themvora/react";
export function Preview({ themeName }: { themeName: string | null }) {
const ref = useRef<HTMLDivElement | null>(null);
useScopedTheme(ref, themeName);
return <div ref={ref}>This div follows {themeName ?? "the global theme"}.</div>;
}3Local themes
Themes defined on the scope itself — for genuinely isolated components that ship their own palette. No second runtime is created.
import { ThemeScope } from "@themvora/react";
import type { ThemeDefinition } from "@themvora/core";
import { defineTheme } from "@themvora/core";
const compactTheme: ThemeDefinition = defineTheme({
name: "compact-light",
meta: { family: "compact", mode: "light" },
tokens: {
colors: {
background: "#ffffff",
foreground: "#18181b",
primary: "#6366f1",
primaryForeground: "#ffffff",
card: "#fafafa",
},
},
});
export function ScopedEditor() {
return (
<ThemeScope themes={[compactTheme]} theme="compact-light">
<EditorToolbar />
</ThemeScope>
);
}local themes resolve first |A scope's
themes are layered on top of the provider's registry: a local theme with the same name shadows the parent's, and anything a local theme doesn't define falls through to the app's themes (its extends chain is merged, so inherited tokens resolve). Late-loaded packs work too — swapping the themes array re-resolves the scope in place. The imperative equivalent passes localThemes to createScopedThemeBinding.4Transitions
Scoped theme changes animate through the same transition engine — inheriting the provider's config unless you say otherwise.
import { ThemeScope } from "@themvora/react";
<ThemeScope theme="plum-dark" />
<ThemeScope theme="plum-dark" transition={false} />
<ThemeScope
theme="plum-dark"
transition={{ duration: 150 }}
/>inheritance model |
Provider transition → Scope → inherited defaults → local overrides. Passing an object merges over the provider's ThemeTransitionOptions (duration, easing, preset, View Transitions), so a scoped change animates smoothly with the rest of the app while letting you tweak just what's different. Reduced-motion users get an instant swap.5How a scope works
A scope is a plain element that emits the theme's variables into its own subtree — nothing leaks outside.
<ThemeProvider>
<ThemeScope theme="mint-light">
<Dashboard />
<ThemeScope theme="plum-dark">
<CodeEditor />
</ThemeScope>
</ThemeScope>
</ThemeProvider>- The scope element renders every token of the target theme as
--theme-*CSS custom properties on itself, so children just consume them. - Tailwind is supported directly — scopes also emit
--color-*and--radius-*aliases, so utilities likebg-cardpick up the scoped palette. - Dark scoped themes add a
darkclass to the scope element, keeping Themvora's "@custom-variant dark" Tailwind trick working inside the island. - Scopes nest arbitrarily — an inner
ThemeScopeoverrides the outer one for its own subtree. - The scope resolves its theme from the registry, so it updates automatically when the global mode changes (family fallback), and re-renders when the underlying theme definition is swapped. It also follows the catalog itself: when the provider's
themeschanges, every scope re-resolves in place — a scope whose theme left the catalog moves to the catalog's first theme rather than keeping stale variables. - Server-rendered scopes pre-paint correctly too: an explicit selection (exact name, or a light/dark family mode) renders its variables inline, while an OS-dependent selection (a
systemmode, or a boundary scope following a system selection) ships a@media (prefers-color-scheme: dark)block so the first paint already matches the OS — no flash of the wrong scoped theme before hydration.