ThemePreview

The rewritten theme — tokens on a real element, seven settings, and per-component radius.

ThemePreview is the next Theme, shipping alongside the existing one so applications can migrate at their own pace. It mounts every token-bearing attribute on a real element instead of <html>, which is what makes the root theme, a nested scope and a portal all behave the same way.

Three long-standing problems close with that change:

  • The root can be server-rendered. Attributes on <html> came only from an effect or a blocking script, so the server emitted nothing. Every setting is now an ordinary prop that renders on the first byte.
  • More than one provider can exist per page. Nothing competes for <html>, so an embedded widget or a second independent root just works.
  • Scoped themes reach into portals. Theme values cross a portal through React context and are re-emitted onto the portalled element, so a popover opened inside a dark scope is dark.

ThemePreview is additive. The existing Theme, useTheme and ThemeSwitcher are unchanged and keep working. Do not nest one inside the other — pick one per application.

Installation

1import { ThemePreview } from "@raystack/apsara";
2
3export default function App() {
4 return (
5 <ThemePreview persistKey="app-theme">
6 <YourApp />
7 </ThemePreview>
8 );
9}

Tokens live on the element ThemePreview renders, so everything that needs them must be inside it. Consumer CSS and hand-rolled portals mounted outside the provider will not resolve --rs-*; the container prop on every portalling component is the supported way to place portalled content back inside the theme.

Theme panel

Every setting, live, next to a sampler of components. The panel is a scope with isRoot={false}, so it re-themes itself without touching the page.

1<ThemePanelDemo />

Settings

One settings object describes the theme. Every key is independently seedable, controllable and persistable, and every key becomes a data attribute on the theme element.

SettingValuesDefaultAttribute
appearancelight, dark, systemsystemdata-theme (resolved)
accentColorindigo, orange, mintindigodata-accent-color
grayColorgray, mauve, slate, sage, autoautodata-gray-color (resolved)
radiusnone, small, medium, large, fullmediumdata-radius
scaling0.9, 0.95, 1, 1.05, 1.11data-scaling
panelBackgroundsolid, translucentsoliddata-panel-background
reducedMotiontrue, false, systemsystemdata-reduced-motion

appearance: "system" resolves against prefers-color-scheme and grayColor: "auto" pairs a complementary gray to the accent, both before the attribute is written — data-theme only ever holds light or dark.

Font families are deliberately not a setting. They are customised through CSS variables instead; see Fonts.

Appearance

1<Flex gap={5} align="start">
2 <ThemePreview
3 isRoot={false}
4 defaultValue={{ appearance: "light" }}
5 style={{ padding: 16, borderRadius: 8 }}
6 >
7 <Flex direction="column" gap={3}>
8 <Text>Light scope</Text>
9 <Button>Primary</Button>
10 </Flex>
11 </ThemePreview>
12
13 <ThemePreview
14 isRoot={false}
15 defaultValue={{ appearance: "dark" }}

Accent

1<Flex gap={5} align="start">
2 {["indigo", "orange", "mint"].map((accent) => (
3 <ThemePreview
4 key={accent}
5 isRoot={false}
6 defaultValue={{ accentColor: accent }}
7 hasBackground={false}
8 >
9 <Flex direction="column" gap={3} align="start">
10 <Text>{accent}</Text>
11 <Button>Primary</Button>
12 <Badge>Badge</Badge>
13 </Flex>
14 </ThemePreview>
15 ))}

Radius

Radius is a factor applied to a fixed base scale, so radius="small" means the same thing in every configuration.

1<Flex gap={5} align="start">
2 {["none", "small", "medium", "large", "full"].map((radius) => (
3 <ThemePreview
4 key={radius}
5 isRoot={false}
6 defaultValue={{ radius }}
7 hasBackground={false}
8 >
9 <Flex direction="column" gap={3} align="start">
10 <Text>{radius}</Text>
11 <Button>Primary</Button>
12 </Flex>
13 </ThemePreview>
14 ))}
15</Flex>

Scaling

Scaling is a zoom, not a density control: it multiplies spacing, radius, type and line height together. Border and divider widths, and font weights, do not scale.

1<Flex gap={5} align="start">
2 {["0.9", "1", "1.1"].map((scaling) => (
3 <ThemePreview
4 key={scaling}
5 isRoot={false}
6 defaultValue={{ scaling }}
7 hasBackground={false}
8 >
9 <Flex direction="column" gap={3} align="start">
10 <Text>{scaling}x</Text>
11 <Button>Primary</Button>
12 </Flex>
13 </ThemePreview>
14 ))}
15</Flex>

Controlled and uncontrolled

defaultValue seeds uncontrolled keys; a stored user choice may override it. value is authoritative: a controlled key always wins, is never persisted and is never written by the inline script.

Control is per key, so a page can drive appearance from a cookie while leaving accent and radius adjustable:

1<ThemePreview
2 value={{ appearance: appearanceFromCookie }}
3 defaultValue={{ accentColor: "mint" }}
4 persistKey="app-theme"
5>
6 <App />
7</ThemePreview>

Persistence

Persistence is off unless persistKey is set. A theme without one holds its settings in memory and emits no inline script, so a nested scope, an embedded widget and a second independent root all keep their own state by default and cannot collide.

1// Persist everything under one namespace
2<ThemePreview persistKey="app-theme" />
3
4// Persist only the appearance; the rest stays in memory
5<ThemePreview persistKey="app-theme" persist={["appearance"]} />

A namespace is one localStorage entry holding one JSON object alongside a schema version. A write merges: it applies only the settings its persist covers and leaves every other field intact, including fields owned by a theme with a different persist on the same namespace. A missing or unparseable entry falls back to the seeded defaults and is overwritten on the next write; a field outside its union is discarded individually.

Sharing a persistKey is a supported feature, not a hazard — it is exactly what a documentation page wants when several live examples should switch appearance together. Themes on one namespace stay in step within a document, and across tabs through the storage event.

Server rendering

Accent, gray, radius, scaling and panel background are ordinary props, so React server-renders them correctly on the first byte. Only appearance can differ between the server and the browser, and only when it is uncontrolled and persisted — which is the one case the inline script covers.

1// Next.js App Router: app/layout.tsx
2import { ThemePreview } from "@raystack/apsara";
3
4export default function RootLayout({ children }) {
5 return (
6 <html lang="en">
7 <body>
8 <ThemePreview persistKey="app-theme">{children}</ThemePreview>
9 </body>
10 </html>
11 );
12}

No suppressHydrationWarning on <html> is needed: nothing is written there. The theme element carries it itself, which suppresses attribute diffs exactly one level deep.

The script renders as the theme element's first child and patches its own parent. It is emitted only for a theme with a persistKey, and then only for that namespace's uncontrolled settings — a consumer reading appearance from a cookie ships no script at all. Pass nonce if your CSP requires one.

Scoping

A nested ThemePreview inherits every key it does not set, so "inherit" is expressed by omission rather than by a value.

1<ThemePreview persistKey="app-theme">
2 {/* Only the accent changes; appearance, radius and scaling inherit */}
3 <ThemePreview defaultValue={{ accentColor: "mint" }}>
4 <Card />
5 </ThemePreview>
6</ThemePreview>

hasBackground

The component cannot infer whether it should paint, because re-tinting the accent and flipping a panel to dark use the same component but want opposite behaviour. hasBackground decides, and its default follows a heuristic: true at the root, true for a nested theme that sets an explicit light or dark appearance, false for one that only changes accent, gray, radius or scaling.

Pass hasBackground={false} if your application paints its own page background. Foreground colour applies regardless.

isRoot

Exactly one theme per document may own the page's colour scheme, which is what the browser paints in the overscroll area, the document scrollbar, the region below short content, and native widget defaults. That theme carries a data-rs-root marker, and <html> derives color-scheme from it with :has() — no JavaScript, nothing written to <html>.

A theme claims the marker when it finds no ancestor theme. A theme that has no ancestor but does not own the page — an embedded widget, a micro-frontend — must pass isRoot={false}. Everything else about it is unchanged: it still carries data-theme, so its own subtree still gets a color-scheme; only the three document-level surfaces defer to the host.

If two elements carry the marker with conflicting appearances, neither wins by position — both rules match :root at equal specificity, so dark wins because it is declared later. Set isRoot={false} on the one that does not own the page.

render

render is the asChild-style escape hatch: it merges the theme's attributes onto an element you supply instead of adding a wrapper node.

1<ThemePreview render={<main className="page" />}>
2 <App />
3</ThemePreview>

The useThemePreview hook

1import { useThemePreview } from "@raystack/apsara";
2
3function AppearanceToggle() {
4 const { resolved, setValue } = useThemePreview();
5 const isDark = resolved.appearance === "dark";
6
7 return (
8 <button onClick={() => setValue({ appearance: isDark ? "light" : "dark" })}>
9 Toggle
10 </button>
11 );
12}

Prop

Type

value is the settings as set, system and auto included; resolved is the settings as applied. Branch on resolved, not value — that is what the viewer is actually looking at.

root is the same shape bound to the root provider, so a control inside a scope can flip the page theme:

1function PageToggle() {
2 const { root } = useThemePreview();
3 return (
4 <button onClick={() => root.setValue({ appearance: "dark" })}>
5 Darken the page
6 </button>
7 );
8}

The hook throws outside a provider rather than returning a silent no-op: every colour token is declared under [data-theme], so a tree with no provider has no colours at all.

ThemePreviewSwitcher

A ready-made icon button that flips between light and dark. It reads resolved.appearance, so system shows the icon for what is actually on screen.

1<ThemePreviewSwitcher />

Prop

Type

Per-component radius

Components take a radius prop with the same five values as the theme setting. Two rules distinguish it from a subtree scope:

  1. It affects only the component it is set on, never anything inside it. Tree-level changes belong to ThemePreview.
  2. It does not compound with the theme radius — a large theme with a small component yields small, not large multiplied by small.
1<ThemePreview
2 isRoot={false}
3 defaultValue={{ radius: "large" }}
4 hasBackground={false}
5>
6 <Flex gap={3} align="center">
7 {/* Follows the theme */}
8 <Button>Large</Button>
9 {/* Overrides it, without compounding */}
10 <Button radius="none">None</Button>
11 <Button radius="small">Small</Button>
12 <Button radius="full">Full</Button>
13 </Flex>
14</ThemePreview>

radius is available on Button, IconButton, Badge, Callout, Chip, Input, TextArea, Image, Avatar, and on the portalled sub-components: Dialog.Content, AlertDialog.Content, Drawer.Content, Popover.Content, Menu.Content, ContextMenu.Content, Select.Content, Combobox.Content, Tooltip.Content, PreviewCard.Content, Command.DialogContent and Tour.Content.

It lives on the portalled sub-component rather than the root because a portal carries nothing forward: <Popover.Content radius="none">, not <Popover radius="none">.

Portals

Every portalling component exposes a container prop, so portalled content can be placed inside a subtree you control:

1<Popover.Content container={panelRef}></Popover.Content>

You rarely need it for theming — the inherited theme is re-emitted onto the portalled element automatically — but it is the supported way to keep portalled content inside a specific scroll container, dialog, or shadow root.

Panel background

panelBackground selects between opaque and translucent overlay surfaces: dialogs, drawers, menus, popovers, selects, comboboxes, tooltips, toasts, preview cards, command palettes and tour cards. The default is solid, so translucency is opt-in.

1<ThemePreview defaultValue={{ panelBackground: "translucent" }}>

Reduced motion

reducedMotion: "system" is the default and honours prefers-reduced-motion, which fifty component stylesheets already respect. A forced "true" collapses the duration tokens to a near-zero value, which neutralises transitions and any animation whose duration comes from a token.

It does not reach animations gated behind @media (prefers-reduced-motion: no-preference) blocks. Converting those is tracked separately.

Overriding tokens

Every --rs-* declaration in the package is wrapped in :where(), so it contributes zero specificity. Every theme element — root, scope or portal re-injection — carries a stable, unhashed rs-theme class. A single-class rule of yours therefore beats every built-in token declaration, without !important and regardless of stylesheet order:

1.rs-theme {
2 --rs-color-background-accent-emphasis: #6d28d9;
3 --rs-radius-3: 10px;
4}

Scope it like any CSS:

1.marketing-page .rs-theme {
2 --rs-font-title: "Playfair Display", serif;
3}

Inline style works too, since tokens now live on a real element:

1<ThemePreview style={{ "--rs-space-5": "18px" }}>

This cuts both ways: an unintended selector can overwrite tokens as easily as an intended one. Given the alternative is overrides that cannot be made to work at all, it is the better failure.

Fonts

Three CSS variables and no prop:

TokenRole
--rs-font-bodyBody text
--rs-font-titleHeadings
--rs-font-monoMonospace
1.rs-theme {
2 --rs-font-body: "Geist", system-ui, sans-serif;
3 --rs-font-title: "Geist", system-ui, sans-serif;
4}

There is no fontFamily prop. A font is a one-time branding choice with no runtime picker, and being free-form it could never be a data attribute like the seven settings. A prop would have to write inline custom properties, which beat every :where()-wrapped token rule — making fonts the one part of the token system you could not override from a stylesheet.

Two stylesheets are published and you import exactly one:

ExportContents
@raystack/apsara/style.cssTokens, components, and the font imports
@raystack/apsara/style-no-fonts.cssTokens and components, no font imports

Custom fonts carry a caveat, not a guarantee. The typography scale pairs pixel font sizes with pixel line heights, and its letter spacing is tuned for Inter. A font with different metrics leaves line heights uncentred and tracking wrong, and because controls are sized by padding plus line-height, their dimensions shift with it.

Migrating from Theme

ThemePreview is a clean break rather than a superset. Migrate a whole application at once; do not nest the two.

RemovedReplacement
themevalue.appearance
defaultThemedefaultValue.appearance
forcedThemevalue.appearance
accentColor, grayColor as flat propsdefaultValue.accentColor, defaultValue.grayColor
styleradius plus the --rs-font-* tokens
onThemeChangeonValueChange
enableSystemappearance: "system"
enableColorSchemeHandled by the stylesheet
themes, attribute, value as a name-to-attribute mapNone. Arbitrary named themes are not supported
ThemeProvider aliasThemePreview
useTheme().theme / .setTheme / .resolvedTheme / .systemThemevalue / setValue / resolved / systemAppearance
useTheme().themes / .forcedTheme / .style / .scopesNone
useTheme({ storageKey })useThemePreview().root
storageKeypersistKey, which now also gates persistence rather than only naming it
Persistence at the root by defaultpersistKey is required to persist, at the root as well as in a scope
ThemeSwitcherThemePreviewSwitcher

Before and after

1// Before
2<Theme
3 defaultTheme="system"
4 storageKey="theme"
5 style="modern"
6 accentColor="orange"
7 grayColor="mauve"
8 onThemeChange={(theme, resolved) => track(resolved)}
9>
10 <App />
11</Theme>
12
13// After
14<ThemePreview
15 persistKey="theme"
16 defaultValue={{
17 appearance: "system",
18 accentColor: "orange",
19 grayColor: "mauve"
20 }}
21 onValueChange={(value, changed) => {
22 if (changed.appearance) track(value.appearance);
23 }}
24>
25 <App />
26</ThemePreview>
1// Before — force dark for a subtree
2<Theme forcedTheme="dark">
3 <Sidebar />
4</Theme>
5
6// After
7<ThemePreview value={{ appearance: "dark" }}>
8 <Sidebar />
9</ThemePreview>
1// Before — flip the page theme from inside a scope
2const { setTheme } = useTheme({ storageKey: "theme" });
3
4// After
5const { root } = useThemePreview();
6root.setValue({ appearance: "dark" });

style is retired

style="modern" | "traditional" decomposed exactly into a radius level plus a font pair, both of which are now first-class. Traditional was not a constant multiple of modern — the two scales ran 2/4/6/8/12/16 and 8/16/20/24/32/40 — so it could not survive as a factor without changing its output. It becomes a recipe instead:

1<ThemePreview defaultValue={{ radius: "large" }}>
1.rs-theme {
2 --rs-font-title: "Lora", serif;
3 --rs-font-body: "Josefin Sans", sans-serif;
4}

Component radius values

Image and Avatar had bespoke radius scales disconnected from the theme; both now use the shared five values.

  • Imagenone, medium and full are unchanged; small is now 0.75× the base step rather than a separate token, and large is new.
  • Avatar — the default moves from small to medium, which renders exactly as the old default did. An explicit radius="small" is now slightly tighter; full is unchanged.

Other things to know

  • Tokens are no longer on <html>, so consumer CSS and hand-rolled portals living outside the provider stop resolving --rs-*. Move them inside, or use a container prop.
  • useThemePreview throws outside a provider instead of returning a no-op.
  • The mono font stack now puts JetBrains Mono ahead of Menlo, so the imported face actually renders on macOS.

API Reference

ThemePreview

Prop

Type

ThemeSettings

Prop

Type