Animation & Transition
@themvora/core — transition pipeline
Themvora owns the entire visual update for a theme change — diff what actually changed, plan which properties may animate, and run one coordinated, lag-free transition. Enable it with a one-line transition prop and pick a preset; nothing else is required.
1Enable it
Themvora ships with enabled: true, 300ms, cubic-bezier(0.4, 0, 0.2, 1), and preset: "smooth". You don't need to pass anything to get animated theme changes. Pass transition={{ enabled: false }} (or transition=) to turn them off.
The full framework-specific setup is in Section 3 below.
Change the configuration after mount
The transition prop is live. Changing duration, easing, preset/properties, useViewTransition or enabled takes effect on the next theme change — no reload, no provider remount, and the theme that is already on screen is not re-applied.
transition={{false}} to make changes instant. Pass a config again to re-enable. An unusable duration/easing is reported as TV_CONFIG_OPTION_INVALID.import { ThemeProvider } from "@themvora/react";
// Renders with an instant switch…
<ThemeProvider transition={false}>
<App />
</ThemeProvider>;
// …then animates the next change, without a remount.
<ThemeProvider transition={{ duration: 600, easing: "ease-in-out" }}>
<App />
</ThemeProvider>;2What animates — presets & properties
Transitions run on the properties that actually changed between themes.preset selects a curated list, or pass an exact properties array to allow only those CSS properties to animate:
transition={{ enabled: true, properties: ["color", "background-color"] }}| Preset | Properties allowed to animate | When to use |
|---|---|---|
smooth | color, background(-color), border-color, outline-color, fill, stroke, border-radius, box-shadow, text-shadow, opacity | Default. Full, rich cross-fade for the whole site — pick this unless you have a reason not to. |
subtle | color, background(-color), border-color, outline-color, fill, stroke, box-shadow, opacity | Lower motion profile — skips radius/shadow/text-shadow so the switch reads quieter. |
instant | none (interpolation disabled) | Never interpolate — always a hard, instant switch (also respects reduced motion). |
[string, ...] | exactly the properties you list | Fine-grained control — e.g. animate only color and background-color, nothing else. |
properties: ["color", "background-color"] animates those properties when they change and leaves everything else to snap. Omit both preset and properties for the default "smooth" set.3Enable transitions in your framework
Pass the same transition object to your provider (or runtime). Every framework exposes it with the same shape — only the binding syntax changes.
import { ThemeProvider, ThemeScope } from "@themvora/react";
export function App() {
return (
<ThemeProvider
defaultTheme="light"
transition={{ enabled: true, preset: "smooth" }}
>
<YourApp />
{/* Nested region with its own theme + transition */}
<ThemeScope
theme="plum-dark"
transition={{ enabled: true, duration: 300, easing: "ease" }}
>
<Widget />
</ThemeScope>
</ThemeProvider>
);
}4View Transitions API
import { createThemeRuntime } from "@themvora/core";
const runtime = createThemeRuntime({
transition: {
enabled: true,
useViewTransition: true,
duration: 300,
},
});useViewTransition: true the new theme is painted beneath a fading snapshot of the old one — the opposite of interpolating directly from near-white colors — so light→dark switches never wash through white. Browsers without the API (Firefox/Safari) fall back to the inherited --theme-color-* interpolation automatically.To opt out and go back to plain CSS-property interpolation, pass useViewTransition: false together with the duration and easing you want — the rest of the transition options are unchanged, and the switch still animates, just without the snapshot.
5The animations plugin
The runtime's own transition engine covers anything that reads the inherited --theme-* variables. When you need to animate a target the runtime does not own — a widget that writes its own properties, a canvas host, a third-party element — use createAnimationsPlugin. It applies a CSS transition rule to one element on onBeforeThemeChange and removes it on onDestroy.
import { createThemeRuntime, createAnimationsPlugin } from "@themvora/core";
// The plugin applies a CSS transition to the target element before every theme
// change, so the token updates it writes animate instead of snapping.
const runtime = createThemeRuntime({
plugins: [
createAnimationsPlugin({
transition: { enabled: true, duration: 300, easing: "ease-in-out" },
}),
],
});It takes two options — the transition to apply and the element to apply it to. The element defaults to document.documentElement in a browser and to null (a no-op) elsewhere, so a themed subtree has to name itself:
import { createAnimationsPlugin } from "@themvora/core";
const plugin = createAnimationsPlugin({
transition: {
enabled: true,
duration: 300,
easing: "ease-in-out",
properties: ["color", "background-color", "border-color"],
},
// Defaults to document.documentElement. Point it at the element the theme
// variables are actually written to.
element: document.querySelector<HTMLElement>("#widget") ?? document.documentElement,
});useViewTransition to false for exactly this reason: it provides CSS transition properties, not page-level cross-fades.6Runtime control
Sometimes one switch must not animate — a first paint, a screenshot, a bulk import. suppressTransition on a store write wins over whatever the live configuration says, for that update only:
import { createThemeRuntime, getBuiltInThemes } from "@themvora/core";
const runtime = createThemeRuntime({ defaultTheme: "light" });
const themes = getBuiltInThemes();
const target = themes.find((theme) => theme.name === "plum-dark") ?? runtime.store.get();
// One switch, no animation — regardless of the configured transition.
runtime.store.set(target, { suppressTransition: true });Framework-free runtimes configure the transition with runtime.configure() and read it back from runtime.transition. The value is live: the next change uses it, and the theme already on screen is not re-applied.
import { createThemeRuntime } from "@themvora/core";
const runtime = createThemeRuntime({ defaultTheme: "light" });
// Replaces the transition used by subsequent changes; the theme on screen is
// not re-applied.
runtime.configure({ transition: { enabled: true, duration: 600 } });
// The live options, after the update above.
const transition = runtime.transition;Token overrides are theme changes too, so they animate on the same path — runtime.update() merges the tokens, resolves references, runs plugin transforms, then commits through the transition:
import { useThemeRuntime } from "@themvora/react";
export function LiveEditor() {
const runtime = useThemeRuntime();
function softenCorners() {
// A token override is a theme change: the configured transition animates
// the radius variables exactly like a light→dark switch.
runtime.update({
radius: { sm: "8px", md: "12px", lg: "16px", xl: "20px" },
});
}
return <button onClick={softenCorners}>Soften all corners</button>;
}7Things to avoid
Theme transitions are owned by themvora. The provider's transition engine (configured via the transition prop on ThemeProvider or createThemeRuntime) interpolates the inherited --theme-color-* variables every time the active theme changes — automatically, with no extra CSS from you. This is how every theme switch on this docs site animates smoothly, and it works the same way in your app. You don't need to (and should not) add your own color transition.
If you do add one — a transition-colors utility class in Tailwind, or a plain transition: background-color 0.3s in native CSS — that element re-eases the same value again, on top of themvora's own interpolation. The result is a double-eased, laggy switch. Leave color transitions to the library, in both Tailwind and hand-written CSS.
/* ── Tailwind ─────────────────────────────────────────────────────── */
/* ✗ DON'T — animating theme tokens via Tailwind utility classes */
.btn {
@apply transition-colors; /* ← re-eases theme colors → lag */
}
.card {
@apply transition-all; /* ← catches managed properties too */
}
/* ✓ DO — animate non-theme properties with Tailwind */
.btn {
@apply transition-transform duration-200;
}
.btn:hover {
@apply -translate-y-0.5; /* hover lift — fine */
}
/* ── Native CSS ───────────────────────────────────────────────────── */
/* ✗ DON'T — color transition on themed elements */
button {
transition: background-color 0.3s; /* ← re-eases inherited var → lag */
}
* {
transition: color 0.2s, background-color 0.2s; /* ← catches everything */
}
/* ✓ DO — animate properties the theme doesn't manage */
.toast {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.card {
transition: box-shadow 0.2s ease, transform 0.2s ease;
}- Tailwind: use
transition-transform/transition-opacitywithhover:scale,hover:translate,hover:shadow. - Native CSS: write
transition: transform 0.2s ease,opacity 0.2s,box-shadow 0.2son toasts, overlays, and cards — none of these are theme-managed. - Both: animate
outline/outline-offsetfor focus rings.
- Tailwind: don't add
transition-colorsto buttons, cards, or links whose colors are theme tokens — the provider already animates those colors on theme change. Avoidtransition-alltoo, which catches the managed properties. - Native CSS: don't write a
transitionforcolor,background-color,border-coloron:root,*, or any themed element — themvora handles that interpolation for you.