Adapters
@themvora/core — the translation layer
An adapter translates a Themvora ThemeDefinition into whatever a specific UI library needs. Core knows nothing about Bootstrap, MUI, shadcn/ui, Ant Design or UnoCSS — it only knows themes, tokens and runtimes. Adapters bridge the gap.
createXxxAdapter factories run in plain TypeScript with zero framework imports. Each framework package wraps the same factories with its own composable, hook or injectable, so the exact same adapter works in React, Vue, Svelte, Solid, Angular, Next, Nuxt, Remix and Astro.| Kind | What it does | Libraries |
|---|---|---|
| CSS-variable | Maintains a live <style> element holding the library's own custom properties (--bs-*, --background, --color-base-*). The library's stylesheet consumes them, so components restyle the instant the theme changes. | Bootstrap, shadcn/ui, daisyUI, Open Props |
| Generated-theme | Builds the library's native theme object and hands it to that library's own provider. The object is rebuilt on every theme change. | MUI, Chakra UI, Ant Design, Mantine |
| Build-time | Exposes tokens as utilities at build time; the utilities reference the live --theme-* variables, so they follow the active theme at runtime. | UnoCSS |
1CSS Variables (default, automatic)
Every CSS-variable adapter follows the same shape: a factory entry for framework-neutral use, framework wrapper subpaths ( /react, /vue, /svelte, /solid, /angular) that take an explicit runtime, and a CSS injector. By default injectCSS: true — the compatibility stylesheet is injected automatically at install time.
import { createShadcnAdapter } from "@themvora/shadcn/factory";
import { createBootstrapAdapter } from "@themvora/bootstrap/factory";
import { createDaisyAdapter } from "@themvora/daisyui/factory";
import { createOpenPropsAdapter } from "@themvora/open-props/factory";
const handle = runtime.adapters.use(createShadcnAdapter());The factory subpath imports adapters without React. The adapter packages own their own framework wrappers behind subpaths, so a Vue app imports from @themvora/shadcn/vue — no React dependency is pulled in.
Bringing your own stylesheet
Auto-injection is idempotent and SSR-safe, so the default is usually what you want. If you already import the library's official CSS — or ship it through your bundler — set injectCSS: false to avoid a second copy. injectCSS is a factory option: the framework wrappers always inject, and only take { strategy }.
import { createThemeRuntime } from "@themvora/core";
import { createBootstrapAdapter } from "@themvora/bootstrap/factory";
// Import the compatibility stylesheet yourself…
import "@themvora/bootstrap/bootstrap.css";
const runtime = createThemeRuntime({ defaultTheme: "light" });
// …then disable auto-injection so it is not injected a second time.
runtime.adapters.use(createBootstrapAdapter({ injectCSS: false }));The path is @themvora/<library>/<library>.css — shadcn/shadcn.css, daisyui/daisyui.css, open-props/open-props.css. The variable style element (the --* values for the current theme) is always managed by the adapter; this option only toggles the static compatibility stylesheet.
2Tailwind CSS v4
The @themvora/tailwind plugin maps resolved tokens to --color-*, --radius-*, --spacing-*, --font-* and --shadow-* utilities that update at runtime.
import { defineConfig } from "tailwindcss";
import { createTailwindPlugin } from "@themvora/tailwind";
export default defineConfig({
plugins: [createTailwindPlugin()],
});With this plugin, classes like bg-primary, text-foreground, rounded-lg and shadow-md all reference the active theme and switch instantly.
3shadcn/ui adapter
Use the React hook for zero-config binding, or register manually via the factory for full control over strategy, plugins and CSS injection.
import { ThemeProvider, useTheme, useThemeRuntime } from "@themvora/react";
import { useShadcnTheme } from "@themvora/shadcn/react";
function App() {
const runtime = useThemeRuntime();
useShadcnTheme(runtime); // one line — the adapter runs itself
const { mode, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>{mode}</button>
// …your shadcn/ui components
);
}For framework-neutral or non-React use, register the adapter manually:
import { createThemeRuntime } from "@themvora/core";
import { createShadcnAdapter } from "@themvora/shadcn/factory";
const runtime = createThemeRuntime({ defaultTheme: "light" });
const handle = runtime.adapters.use(createShadcnAdapter());
// switch themes at runtime — variables update automatically
runtime.selection.setMode("dark");
// cleanup when done
handle.dispose();4Ant Design adapter
The @themvora/antd package provides useAntdTheme and AntdThemeProvider. The theme object is rebuilt automatically on every theme change.
import { ThemeProvider, useThemeRuntime } from "@themvora/react";
import { useAntdTheme } from "@themvora/antd";
function AntdZone() {
const runtime = useThemeRuntime();
useAntdTheme(runtime);
return <YourAntdApp />;
}
export function App() {
return (
<ThemeProvider>
<AntdZone />
</ThemeProvider>
);
}The same pattern applies to MUI (useMuiTheme), Chakra (useChakraTheme) and Mantine (useMantineTheme) — each exposes a hook and provider in the same shape.
5UnoCSS adapter
Add presetThemvora() to your UnoCSS config. Utilities like bg-primary, text-foreground, border-border and font-sans resolve to the active theme.
import { defineConfig, presetWind3 } from "unocss";
import { presetThemvora } from "@themvora/unocss";
export default defineConfig({
presets: [presetWind3(), presetThemvora()],
});For build-time static output instead of runtime variables, use createUnoTheme:
import { createUnoTheme } from "@themvora/unocss";
import { getBuiltInThemes, resolveInitialTheme } from "@themvora/core";
// resolveInitialTheme takes the registry explicitly; the built-in set is a
// valid registry, so no theme file is needed.
const themes = getBuiltInThemes();
const { theme } = resolveInitialTheme({
themes,
family: "berry",
mode: "light",
});
const staticTheme = createUnoTheme(theme);6Strategies
| Strategy | Behavior |
|---|---|
exact | Only map what Themvora defines. Change nothing else. |
native | Behave like the library: derive missing semantic concepts (success / warning / info) and make small feel adjustments. Default. |
aggressive | Fully emulate the library: also adjust spacing, typography, elevation, saturation and contrast. |
import { createThemeRuntime } from "@themvora/core";
import { createBootstrapAdapter } from "@themvora/bootstrap/factory";
const runtime = createThemeRuntime({ defaultTheme: "light" });
// "native" (the default) behaves like the library and derives the semantic
// concepts it expects. "aggressive" also adjusts spacing, typography,
// elevation, saturation and contrast.
runtime.adapters.use(createBootstrapAdapter({ strategy: "aggressive" }));The framework wrappers take the same option as their second argument:
import { useThemeRuntime } from "@themvora/react";
import { useShadcnTheme } from "@themvora/shadcn/react";
export function InstallAdapter() {
const runtime = useThemeRuntime();
// The framework wrappers forward the strategy to the factory they create.
useShadcnTheme(runtime, { strategy: "exact" });
return null;
}7Adapter plugins
A plugin is an object with two optional hooks. refine receives the refined semantic state and may return updates to it; transform receives the generated library variables and must return the final set. Both get the current strategy and mode as context. Pass them through the factory options:
import { createThemeRuntime } from "@themvora/core";
import { createShadcnAdapter } from "@themvora/shadcn/factory";
const runtime = createThemeRuntime({ defaultTheme: "light" });
runtime.adapters.use(
createShadcnAdapter({
strategy: "aggressive",
plugins: [
{
id: "rounded",
// Receives the generated library variables, returns the final set.
transform(variables) {
return { ...variables, "--radius-md": "0.75rem" };
},
},
],
}),
);The framework wrappers do not take plugins — they create the adapter for you and only forward strategy. To use a plugin, build the adapter with the factory and register it through runtime.adapters.use().
8Lifecycle & the registry
runtime.adapters.use() installs an adapter and returns a AdapterRegistration whose dispose() uninstalls it. Several adapters can be installed at once — they write different sets of variables and do not conflict:
import { createThemeRuntime } from "@themvora/core";
import { createBootstrapAdapter } from "@themvora/bootstrap/factory";
import { createDaisyAdapter } from "@themvora/daisyui/factory";
const runtime = createThemeRuntime({ defaultTheme: "light" });
const bootstrap = runtime.adapters.use(createBootstrapAdapter());
const daisy = runtime.adapters.use(createDaisyAdapter());
bootstrap.dispose(); // bootstrap uninstalled; daisy stays installed
runtime.destroy(); // tears daisy down too — daisy.dispose() is now a safe no-opThe guarantees that make this safe to compose — React Strict Mode, Svelte lifecycles, nested providers — are per-instance idempotency, reference counting, and replacement on identity change:
import { createThemeRuntime } from "@themvora/core";
import { createShadcnAdapter } from "@themvora/shadcn/factory";
const runtime = createThemeRuntime({ defaultTheme: "light" });
// Same instance twice: installed once, reference count 2.
const adapter = createShadcnAdapter();
const h1 = runtime.adapters.use(adapter);
const h2 = runtime.adapters.use(adapter);
h1.dispose(); // 2 → 1 — still installed
h2.dispose(); // 1 → 0 — uninstall() runs exactly once
// A different instance under the same id replaces the previous one.
const first = runtime.adapters.use(createShadcnAdapter({ strategy: "exact" }));
const second = runtime.adapters.use(createShadcnAdapter({ strategy: "aggressive" }));
first.dispose(); // stale handle — safe no-op
second.dispose(); // uninstalls the aggressive adapterunuse(id) force-uninstalls every registration for an id and is deprecated — hold the handle from use() instead. list() returns the installed adapters, and destroy() uninstalls everything. The framework bindings all take this same path, so a composition API, an effect cleanup, onUnmounted, onDestroy, onCleanup and DestroyRef.onDestroy all behave identically.