Astro
Astro-native theming: an integration for the pre-paint bootstrap, a React-free <html> layout, a native <ThemeToggle />, and opt-in client islands.
@themvora/astroAstrov2.0.0StableSSR:YesZero-flash:YesOverview
Astro is not one setup. Themvora supports two application shapes here, and they differ in exactly one thing: whether the app ships a client framework runtime at all.
The theme registry, the pre-paint bootstrap, the server-rendered <html> and the readout contract are identical in both. Only the interactive UI changes — a native Astro component plus a plain browser <script>, or a React island.
Pick the shape first, then follow one path. The snippets in each path are complete for that shape, and the two paths do not overlap.
Choose your setup
Both paths are fully supported. Pick the one that matches how you build interactive UI in this app — the setup switch below shows that path's steps only, and the shared behavior is explained once, after both.
Which one should I use?
- Choose Astro-only when your UI can stay in Astro and browser scripts, and you do not need a client UI framework for Themvora UI.
- Choose Astro + React when interactive themed UI is implemented inside React islands.
- Neither is more capable than the other: both paths drive one runtime, and a page can mix them — an island adopts a runtime that a
<ThemeToggle />orgetThemeController()script already installed, instead of creating a second one.
| Area | Astro-only | Astro + React |
|---|---|---|
Astro integration — themvora() | Yes | Yes |
@astrojs/react + react | No | Yes |
| React runtime in the bundle | No | Yes |
| React island | No | optional — only for React UI |
React hooks (useTheme) | No | Yes |
Astro-native usage — <ThemeToggle />, getThemeController() | Yes | Yes |
| SSR + pre-paint bootstrap | shared | shared |
Theme definitions — theme.config.ts | shared | shared |
“Shared” means the two paths do not differ — there is one implementation, described under Shared behavior below.
Astro-only
No React runtimeUse this when your application is Astro-native and does not need a client framework for Themvora UI.
Install
Two packages, neither of which pulls in a client framework.
pnpm add @themvora/core @themvora/astroConfigure
Three files. theme.config.ts declares the themes and the starting mode, astro.config.mjs registers the integration, and the layout mounts provider.astro, which server-renders the themed <html>.
import { defineTheme, defineThemvoraConfig } from "@themvora/core";
const themes = [
defineTheme({
name: "mint-light",
meta: { family: "mint", mode: "light", label: "Mint Light", order: 10 },
tokens: { colors: { background: "#f8fafc", foreground: "#0f172a" } },
}),
defineTheme({
name: "mint-dark",
meta: { family: "mint", mode: "dark", label: "Mint Dark", order: 20 },
tokens: { colors: { background: "#0f172a", foreground: "#f8fafc" } },
}),
];
export default defineThemvoraConfig({
themes,
defaultTheme: "mint-light",
initialMode: "system",
initialFamily: "mint",
});import { defineConfig } from "astro/config";
import themvora from "@themvora/astro";
export default defineConfig({
integrations: [themvora()],
});---
// The one place the provider is mounted.
import Provider from "@themvora/astro/provider.astro";
import themeConfig from "../../theme.config";
import "../styles.css";
---
<Provider config={themeConfig} lang="en" bootstrap={false}>
<slot />
</Provider>themvora() discovers theme.config.ts at the project root, and the integration, provider.astro and any island all read that one file — so nothing is restated. Nothing in this path imports a client framework: the integration, the server helpers and the native components are framework-neutral, so this path installs no renderer and loads no client framework bundle on the page.Add a small UI
A status card with a heading, a description, a status label and a primary button. Native .astro markup uses class; inside a React island the same tree uses className. Nothing is styled yet.
---
// Nothing to run here: the classes read the variables the provider applies.
---
<div class="themvora-example-card">
<div class="themvora-example-header">
<h3 class="themvora-example-title">Design system</h3>
<span class="themvora-example-status">Active</span>
</div>
<p class="themvora-example-description">
Tokens resolve at runtime, so this card follows whichever theme is active.
</p>
<p class="themvora-example-meta">Updated 2 minutes ago</p>
<button class="themvora-example-button" type="button">View themes</button>
</div>.themvora-example-* classes are used by every framework guide in these docs.Use Themvora
<ThemeToggle /> is a real <button>, so keyboard and focus behavior come from the platform. class is forwarded to that button, so a token-driven class of your own is the whole styling story — there is no ::part, no wrapper element and no theme prop to learn.
---
import BaseLayout from "../layouts/BaseLayout.astro";
import ThemeToggle from "@themvora/astro/ThemeToggle.astro";
---
<BaseLayout>
<h1>Themed with no client framework</h1>
<!-- class is forwarded to the real <button>, so your own token-driven class
styles it — here the example button the stylesheet defines. -->
<ThemeToggle class="themvora-example-button" />
<!-- showMode makes the visible text the live mode. The accessible name stays
an action, because "dark" does not say what the button does. -->
<ThemeToggle class="themvora-example-button" showMode ariaLabel="Toggle colour scheme" />
</BaseLayout>showMode makes the visible text the live mode, and ariaLabel keeps the accessible name an action — with showMode the visible text is a state ("dark"), which does not say what the button does. label is the static alternative, and is ignored while showMode is set.Toggle without the component
<ThemeToggle /> is a convenience, not the contract. The same runtime is reachable from a plain <script>, so you can render whatever markup you like and drive it yourself — still with no client framework.
---
// No <ThemeToggle />, no island.
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout>
<h1>Your own toggle</h1>
<!-- The attribute *is* the contract. Nothing binds to this element by id. -->
<button class="btn" data-tv-toggle>Toggle theme</button>
<script>
// One call, once per document. It delegates clicks from any element
// carrying data-tv-toggle, so it does not matter how many toggles this page
// rendered, and a toggle that a client-side navigation swaps in later works
// without this script running again.
import { installThemeBindings } from "@themvora/astro/runtime";
installThemeBindings();
</script>
</BaseLayout>// The one runtime the provider, <ThemeToggle /> and any island share.
import { getThemeController } from "@themvora/astro/runtime";
const theme = getThemeController();
theme.getTheme(); // the resolved ThemeDefinition
theme.getMode(); // "light" | "dark" | "system" — the *selection*
theme.getResolvedMode(); // "light" | "dark" — what is actually painted
theme.getFamily();
theme.setMode("dark");
theme.setFamily("mint");
theme.toggleTheme();
theme.subscribe(({ theme, mode, family }) => { /* ... */ });data-tv-toggle on its button and delegates clicks from any element carrying that attribute, so your own markup can be a toggle once the component is on the page. Delegation is not a style preference: Astro bundles a page's module <script> and never re-executes it across a <ClientRouter /> navigation, so a per-element getElementById(...).addEventListener(...) binds once, matches at most one node, and is dead for any page you navigate back to. installThemeBindings() registers the delegated listeners once per document, which is what makes them survive. data-tv-readout is the matching contract for text Themvora owns: the pre-paint bootstrap patches it from <head>, so a server-rendered label is never painted and then corrected.Select a theme family
<ThemeFamilySelect /> renders one <select> per dimension from theme.config.ts. Hand-written markup uses the same contract: put data-tv-select="family" or "mode" on the element.
---
// Native controls, no script of your own.
import BaseLayout from "../layouts/BaseLayout.astro";
import ThemeFamilySelect from "@themvora/astro/ThemeFamilySelect.astro";
import ThemeToggle from "@themvora/astro/ThemeToggle.astro";
---
<BaseLayout>
<h1>Appearance</h1>
<!-- Renders one <select> per dimension from theme.config.ts. -->
<ThemeFamilySelect />
<!-- Or hand-write the markup: data-tv-select is the whole contract. -->
<label>
Mode
<select data-tv-select="mode">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</label>
<ThemeToggle />
<p>
mode: <span data-tv-readout="mode">—</span> ·
family: <span data-tv-readout="family">—</span> ·
theme: <span data-tv-readout="theme">—</span>
</p>
</BaseLayout>data-tv-select owns both directions. A change event is delegated from document, so every control is live however many the page rendered; and the live selection is written back into the element's value, so two controls of a kind never disagree and a selection restored from a cookie is displayed rather than shown as the first option. This is the case a per-element binding cannot cover: one id matches one node, and a module script that already ran will not run again.Scope a subtree
<ThemeScope /> paints its children with a theme of its own. The variables are resolved in the frontmatter, so the scoped panel is correct in the server markup — no first-frame flash, and no client JavaScript unless the page already has a runtime.
---
// A panel that is always one theme.
import BaseLayout from "../layouts/BaseLayout.astro";
import ThemeScope from "@themvora/astro/ThemeScope.astro";
---
<BaseLayout>
<h1>Pricing</h1>
<!--
Painted cocoa-dark whatever the visitor selects. The variables are in the
server markup, so the panel is never painted with the page theme first.
-->
<ThemeScope theme="cocoa-dark" as="section" class="rounded-xl p-6">
<h2>Team</h2>
<p>Everything inside reads the scoped tokens.</p>
</ThemeScope>
<!-- Scopes are matched by attribute, not id: render as many as you like. -->
<ThemeScope theme="ocean-dark" as="section" class="rounded-xl p-6">
<h2>Enterprise</h2>
</ThemeScope>
</BaseLayout>theme is required and must exist in the registry; an unknown name throws with the available names rather than silently falling back to the page theme. as chooses the wrapper element, class merges with the dark marker the component adds for a dark theme, and transition is honoured once a runtime exists. Outside a <Provider> — or against a different registry — pass themes.Client-side navigation
With <ClientRouter />, Astro replaces the body and never re-executes a page's module <script>. themvora() injects a before-swap script that carries the live theme, the readouts and the selection onto the incoming document.
---
// With client-side navigation.
import { ClientRouter } from "astro:transitions";
import Provider from "@themvora/astro/provider.astro";
import themeConfig from "../../theme.config";
import "../styles.css";
---
<Provider config={themeConfig} lang="en" bootstrap={false}>
<!--
With <ClientRouter />, Astro swaps the body and never re-executes a
page's module <script> again. themvora() injects a `before-swap` script
that carries the live theme onto the incoming document, so the theme,
the readouts and every data-tv-select control survive navigation.
-->
<ClientRouter slot="head" />
<slot />
</Provider><html> attributes would be whatever that page was built with, so the theme would revert on every navigation until an island re-hydrated. The script is inert on a site that does not use <ClientRouter />, and navigation: false turns it off.Style it with Themvora tokens
Your own classes read the variables the active theme emits, so there is no per-theme CSS to write and nothing to re-render on a switch.
/* 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;
}import "../styles.css" — so the rules apply to every page.Complete example
The same page with a themed panel, two toggles and a live readout.
---
import BaseLayout from "../layouts/BaseLayout.astro";
import ThemeToggle from "@themvora/astro/ThemeToggle.astro";
---
<BaseLayout>
<main>
<h1>Themed with no client framework</h1>
<ThemeToggle />
<ThemeToggle showMode />
<section class="themvora-example-card">
<div class="themvora-example-header">
<h2 class="themvora-example-title">Semantic tokens</h2>
<span class="themvora-example-status">Connected</span>
</div>
<p class="themvora-example-description">
This panel is themed by <code>--theme-color-card</code>, a
<code>--theme-color-border</code> outline and
<code>--theme-color-cardForeground</code> text — no hardcoded color.
</p>
<p class="themvora-example-meta">
mode: <span data-tv-readout="mode">—</span>
</p>
<button class="themvora-example-button" type="button">View themes</button>
</section>
</main>
</BaseLayout>.themvora-example-card looks. Because those classes read variables, switching the theme re-paints them with no re-render and no per-theme CSS.Astro + React
React islandsUse this when Astro renders the page shell but interactive themed UI is implemented with React islands.
Install
The same two Themvora packages, plus the React renderer Astro needs to hydrate an island.
pnpm add @themvora/core @themvora/astro @astrojs/react react react-dom@astrojs/react, react and react-dom. The Themvora side is unchanged — @themvora/astro/client is a subpath of the package you already have, not a second package.Configure
Add the React integration next to themvora(). That registration is the whole configuration difference between the two paths.
// The only addition is the React renderer.
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import themvora from "@themvora/astro";
export default defineConfig({
integrations: [themvora(), react()],
});theme.config.ts is unchanged from the Astro-only path. The integration, provider.astro and the island all read that one file, so there is nothing to restate here.Add a small UI
The same status card as a React component. Because the island is JSX, the class attribute is className; the classes and the stylesheet are identical to the Astro-only path.
---
// Nothing to run here: the classes read the variables the provider applies.
---
<div class="themvora-example-card">
<div class="themvora-example-header">
<h3 class="themvora-example-title">Design system</h3>
<span class="themvora-example-status">Active</span>
</div>
<p class="themvora-example-description">
Tokens resolve at runtime, so this card follows whichever theme is active.
</p>
<p class="themvora-example-meta">Updated 2 minutes ago</p>
<button class="themvora-example-button" type="button">View themes</button>
</div>WorkspaceCard.tsx. In this shape it lives inside the island's tree, so it hydrates alongside the provider rather than in a separate island.Add React island
Write one island whose tree contains both the provider and its consumers. ThemeProviderClient renders nothing — it installs the runtime — so the panel that reads it is a sibling inside the same React tree.
import { ThemeProviderClient, useTheme } from "@themvora/astro/client";
function Panel() {
const { theme, mode, setMode, 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>
);
}
// ThemeProviderClient renders nothing — it installs the runtime. Panel is a
// sibling that reads it, and both hydrate as one island.
export default function ThemeIsland() {
return (
<>
{/*
No theme props. The registry, the default theme and the initial
mode/family all come from the theme.config.ts the integration
transported to the browser, so restating initialMode/initialFamily here
would be a second source of truth for a decision the app already made.
*/}
<ThemeProviderClient />
<Panel />
</>
);
}
// Mount it from a page:
// <ThemeIsland client:load />Native controls need no client runtime
Adding a renderer does not take the native component away. <ThemeToggle /> is still a real <button> with no island and no client:load, class still styles it, and a plain <script> can drive the runtime instead of the component — so a page can mix native controls with the island.
---
import BaseLayout from "../layouts/BaseLayout.astro";
import ThemeToggle from "@themvora/astro/ThemeToggle.astro";
---
<BaseLayout>
<h1>Themed with no client framework</h1>
<!-- class is forwarded to the real <button>, so your own token-driven class
styles it — here the example button the stylesheet defines. -->
<ThemeToggle class="themvora-example-button" />
<!-- showMode makes the visible text the live mode. The accessible name stays
an action, because "dark" does not say what the button does. -->
<ThemeToggle class="themvora-example-button" showMode ariaLabel="Toggle colour scheme" />
</BaseLayout>---
// No <ThemeToggle />, no island.
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout>
<h1>Your own toggle</h1>
<!-- The attribute *is* the contract. Nothing binds to this element by id. -->
<button class="btn" data-tv-toggle>Toggle theme</button>
<script>
// One call, once per document. It delegates clicks from any element
// carrying data-tv-toggle, so it does not matter how many toggles this page
// rendered, and a toggle that a client-side navigation swaps in later works
// without this script running again.
import { installThemeBindings } from "@themvora/astro/runtime";
installThemeBindings();
</script>
</BaseLayout><ThemeToggle /> or a getThemeController() script already installed, rather than creating a second one. A page that only needs the themed document — no interactive control at all — still ships no client JavaScript: provider.astro and the inline bootstrap do that work on the server.Use React APIs
Read the theme with useTheme() and render values with ThemeReadout. The .astro page only mounts the island with a hydration directive.
// The React half of the island.
import { ThemeReadout, useTheme } from "@themvora/astro/client";
export function ThemePanel() {
// Setters only. The values this hook returns are for behaviour, not display:
// they read through `useSyncExternalStore`, so during hydration they hold the
// server's resolution and a line rendered from them changes a few frames
// after paint.
const { setMode, setFamily, toggleTheme } = useTheme();
return (
<section>
<p>
theme: <ThemeReadout kind="theme" fallback="—" /> · mode:{" "}
<ThemeReadout kind="mode" fallback="—" /> · family:{" "}
<ThemeReadout kind="family" fallback="—" />
</p>
<button onClick={() => toggleTheme()}>Toggle light / dark</button>
<button onClick={() => setMode("system")}>System</button>
<button onClick={() => setFamily("mint")}>Mint family</button>
<p>
Resolved: <ThemeReadout kind="label" fallback="—" /> · selection:{" "}
<ThemeReadout kind="mode" fallback="—" /> /{" "}
<ThemeReadout kind="family" fallback="—" />
</p>
</section>
);
}---
// The Astro half: mount the island, nothing else.
import BaseLayout from "../layouts/BaseLayout.astro";
import ThemeIsland from "../components/ThemeIsland";
---
<BaseLayout>
<h1>React island</h1>
<p>
Interactive themed UI lives inside the island. The page itself stays Astro.
</p>
<ThemeIsland client:load />
</BaseLayout>.astro files, and useTheme() cannot be called from frontmatter. The boundary is explicit: an Astro page renders a React island, the island renders React components, and only those components call the React Themvora API. Note that every theme value on screen is a ThemeReadout, including the ones useTheme() also returns: those are read through useSyncExternalStore, so during hydration they still hold the server's resolution — which cannot know the persisted selection or prefers-color-scheme — and a line rendered from them changes from one theme name to another a few frames after paint. Compare and derive with them; display with a readout.Scope a subtree
ThemeScope from @themvora/astro/client is the island-side equivalent of ThemeScope.astro: the same props, the same resolved-in-render variables, and the same independence from the app-wide selection.
// A React subtree with its own theme.
import { ThemeScope } from "@themvora/astro/client";
export function PricingPanel() {
return (
<>
<ThemeScope theme="cocoa-dark" className="rounded-xl p-6">
<h2>Team</h2>
<p>Always cocoa-dark, whatever the visitor selects.</p>
</ThemeScope>
<ThemeScope theme="ocean-dark" className="rounded-xl p-6">
<h2>Enterprise</h2>
</ThemeScope>
</>
);
}ThemeScope.astro inside an .astro page, ThemeScope from @themvora/astro/client inside a React island. Both resolve the variables during render, so neither flashes; both are independent of the selection, and both are matched by attribute rather than id, so a page can render as many as it needs.Style it with Themvora tokens
Your own classes read the variables the active theme emits, so there is no per-theme CSS to write and nothing to re-render on a switch.
/* 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;
}Complete example
The React path is these three files: the island component, the panel it renders, and the Astro page that mounts it. The island adopts the runtime the native controls use, so this page and the Astro-only page can coexist in one app.
src/components/ThemeIsland.tsx (the island), src/components/ThemePanel.tsx (the React UI), src/pages/island.astro (the hydration directive). The themed document itself is still rendered by provider.astro in the shared layout.Where React lives
Shared behavior
These are identical in both paths — there is one implementation, and choosing a path does not change any of it. They are explained once, here.
Examples
Two complete, runnable apps — one per path, each built from this guide alone. They share no code, so either can be read on its own and copied into a project as-is.
src/pages/index.astro — themed, toggled, selected and read out with no client framework. It uses <ThemeToggle />, <ThemeFamilySelect /> and <ThemeScope />; no React is loaded on any page.
src/pages/index.astro + src/components/ThemeIsland.tsx — one island (client:load) containing the provider and its consumers, and a /native page that shows the native controls and the island sharing one runtime.
What's Available
@themvora/astro ships 33 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 Astro examples.
Resolve the initial theme on the server
getInitialThemeState reads the request cookies, so the document and any island render one resolution.
---
import { getInitialThemeState } from "@themvora/astro";
import { getBuiltInThemes } from "@themvora/core";
const themes = getBuiltInThemes();
// Server-rendered page: resolve once from the request. Pass the same object to
// provider.astro and to the island's initial prop, so the three cannot drift.
const initial = getInitialThemeState(Astro.request, { themes });
---
<html data-theme={initial.theme.name}>
<body><slot /></body>
</html>Zero-flash script + CSS map
Build the blocking script and precompute CSS variables server-side, when you render the document yourself.
---
import { createBlockingScript, buildThemeCssMap, computeFingerprint } from "@themvora/astro";
import { getBuiltInThemes } from "@themvora/core";
// computeFingerprint/buildThemeCssMap take the registry positionally; the
// built-in set (neutral light/dark + the preset families) is a valid registry,
// so no theme file is needed.
const themes = getBuiltInThemes();
const fingerprint = computeFingerprint(themes, "light");
const cssMap = buildThemeCssMap(themes);
const script = createBlockingScript(fingerprint, cssMap);
---
<html data-theme="light" style={cssMap["light"]}>
<head><Fragment set:html={script} /></head>
<body><slot /></body>
</html>Scope a subtree
ThemeScope overrides the theme for its own subtree. It is a sibling of the provider, not a child — ThemeProviderClient renders nothing, so children passed to it are dropped.
// src/components/ScopedChart.tsx — inside the React island.
import { ThemeProviderClient, ThemeScope } from "@themvora/astro/client";
export function ScopedChart() {
return (
<>
{/* Installs the runtime; renders nothing itself. */}
<ThemeProviderClient />
<ThemeScope theme="plum-dark">
<DataViz />
</ThemeScope>
</>
);
}Shared runtime across islands
getGlobalRuntime() returns the runtime the provider installed, so a browser script and an island share one instance.
<script>
// A browser bundle: import from /runtime, not the package root, which also
// exports the build integration and would pull node:url into the bundle.
import { getGlobalRuntime } from "@themvora/astro/runtime";
const runtime = getGlobalRuntime();
console.log(runtime?.store.get().name ?? "not initialized yet");
</script>Smooth theme transitions
Enable CSS transitions on theme changes for a polished user experience.
// src/components/ThemeIsland.tsx
import { ThemeProviderClient, useTheme } from "@themvora/astro/client";
function Switcher() {
const { theme, toggleTheme } = useTheme();
return <button onClick={toggleTheme}>{theme.name}</button>;
}
export function ThemeIsland() {
return (
<>
{/* transition is a provider prop; the components that read the runtime
are siblings, never children. */}
<ThemeProviderClient
transition={{ enabled: true, duration: 300, easing: "ease-in-out" }}
/>
<Switcher />
</>
);
}