ThemePreview

Mounts the theme on an element. Seven settings, nested scopes, and portals that follow.

1<ThemePanelDemo />

ThemePreview is the next Theme. It renders a real element that carries every token, so the root theme, a nested scope and a portalled popup all use the same component. It server-renders, more than one can exist per page, and a popup opened inside a scope keeps that scope's theme.

ThemePreview is additive. Theme, useTheme and ThemeSwitcher are unchanged. Use one or the other per application; do not nest them.

Usage

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 anything that reads --rs-* must be inside it. Portalled components re-emit the theme onto their popups; only your own portals need to move inside.

Settings

Every setting is a key of one object. Each key can be seeded, controlled or persisted on its own, and each becomes a data attribute on the theme element.

SettingValuesDefaultAttribute
appearancelight, dark, systemsystemdata-theme
accentColorindigo, orange, mintindigodata-accent-color
grayColorgray, mauve, slate, sage, autoautodata-gray-color
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

system and auto are resolved before the attribute is written, so data-theme is always light or dark. Fonts are CSS variables, not a setting; see Fonts.

Appearance

1<Flex gap={5} align="start">
2 {["light", "dark"].map((appearance) => (
3 <ThemePreview
4 key={appearance}
5 isRoot={false}
6 defaultValue={{ appearance }}
7 style={{
8 padding: "var(--rs-space-5)",
9 borderRadius: "var(--rs-radius-4)",
10 }}
11 >
12 <Flex direction="column" gap={3} align="start">
13 <Text>{appearance}</Text>
14 <Button>Primary</Button>
15 <Input placeholder="Input" />

Accent color

grayColor: "auto" pairs a gray to the 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

A factor over a fixed base scale. Controls such as Button become pills only at full; round controls such as Switch stay round from medium up and square off at none and small. Surfaces never become pills.

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 <Switch defaultChecked />
13 </Flex>
14 </ThemePreview>
15 ))}

Scaling

A zoom: spacing, radius, type and line height scale together. Borders and font weights do not.

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>

Panel background

Overlay surfaces are opaque by default. translucent blurs what is behind dialogs, drawers, menus, popovers, selects, tooltips and toasts.

1<Flex
2 gap={5}
3 style={{
4 width: "100%",
5 padding: "var(--rs-space-7)",
6 borderRadius: "var(--rs-radius-4)",
7 background:
8 "linear-gradient(135deg, var(--rs-color-background-accent-emphasis), var(--rs-color-background-attention-emphasis))",
9 }}
10>
11 {["solid", "translucent"].map((panelBackground) => (
12 <ThemePreview
13 key={panelBackground}
14 isRoot={false}
15 defaultValue={{ panelBackground }}

Reduced motion

system follows prefers-reduced-motion. "true" collapses the duration tokens, which stops transitions and any animation timed by a token.

1<ThemePreview defaultValue={{ reducedMotion: "true" }}>

Nesting

A nested ThemePreview inherits every key it does not set.

1<ThemePreview
2 isRoot={false}
3 defaultValue={{ appearance: "light", accentColor: "indigo" }}
4 style={{
5 width: "100%",
6 padding: "var(--rs-space-5)",
7 borderRadius: "var(--rs-radius-4)",
8 }}
9>
10 <Flex direction="column" gap={4}>
11 <Flex gap={3} align="center">
12 <Text size="small" variant="secondary" style={{ width: 200 }}>
13 indigo, medium
14 </Text>
15 <Button>Button</Button>

A scope with its own light or dark appearance paints its background. One that only changes accent, gray, radius or scaling is transparent. hasBackground overrides either.

1<ThemePreview
2 isRoot={false}
3 defaultValue={{ appearance: "light" }}
4 style={{
5 width: "100%",
6 borderRadius: "var(--rs-radius-4)",
7 border: "1px solid var(--rs-color-border-base-primary)",
8 overflow: "hidden",
9 }}
10>
11 <Flex align="stretch">
12 {/* A dark scope paints its own background */}
13 <ThemePreview
14 defaultValue={{ appearance: "dark" }}
15 style={{ width: 200, padding: "var(--rs-space-4)" }}

Portals

Theme values travel through React context, so every portalling component re-emits them onto its popup. A menu opened inside a dark scope is dark, with nothing to configure.

1<ThemePreview
2 isRoot={false}
3 defaultValue={{ appearance: "dark", accentColor: "mint" }}
4 style={{ padding: "var(--rs-space-6)", borderRadius: "var(--rs-radius-4)" }}
5>
6 <Flex gap={3} align="center">
7 <Popover>
8 <Popover.Trigger render={<Button variant="outline">Popover</Button>} />
9 <Popover.Content>
10 <Text size="small">Rendered in a portal, themed by the scope.</Text>
11 </Popover.Content>
12 </Popover>
13
14 <Select defaultValue="mint">
15 <Select.Trigger style={{ width: 140 }}>

isRoot

One theme per document owns the page colour scheme: the scrollbar, the overscroll area and native widget defaults. The first theme with no ancestor claims it. An embedded widget that has no ancestor theme but does not own the page must pass isRoot={false}.

1<ThemePreview isRoot={false} defaultValue={{ appearance: "dark" }}>
2 <Widget />
3</ThemePreview>

render

Merge the theme onto your own element instead of adding a wrapper:

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

Controlled

defaultValue seeds a key; value controls it. A controlled key always wins, is never persisted and is never written by the inline script. Control is per key.

1(function ControlledScope() {
2 const [dark, setDark] = React.useState(false);
3
4 return (
5 <Flex direction="column" gap={4} align="start">
6 <Flex gap={3} align="center">
7 <Switch checked={dark} onCheckedChange={setDark} />
8 <Text size="small">Dark</Text>
9 </Flex>
10
11 <ThemePreview
12 isRoot={false}
13 value={{ appearance: dark ? "dark" : "light" }}
14 style={{
15 padding: "var(--rs-space-5)",
1// Appearance from a cookie; accent stays adjustable and persisted
2<ThemePreview
3 value={{ appearance: appearanceFromCookie }}
4 defaultValue={{ accentColor: "mint" }}
5 persistKey="app-theme"
6>
7 <App />
8</ThemePreview>

Persistence

Off unless persistKey is set. A theme without one keeps its settings in memory, so scopes and embedded widgets never collide. persist narrows which keys the namespace stores.

1// Everything under one namespace
2<ThemePreview persistKey="app-theme" />
3
4// Only the appearance
5<ThemePreview persistKey="app-theme" persist={["appearance"]} />

Themes sharing a persistKey stay in step, across tabs as well. Writes merge into the stored object, so themes with different persist lists can share one key.

Server rendering

Settings are props, so the server renders them. An inline script, the theme element's first child, patches in what the server cannot know before first paint: stored values, and the OS answer for a system appearance. A pinned appearance with no persistKey ships no script. Pass nonce if your CSP needs one.

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}

Nothing is written to <html>, so it needs no suppressHydrationWarning.

useThemePreview

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 what was set, system and auto included; resolved is what is on screen. root is the same handle bound to the root provider, so a control inside a scope can change the page:

1const { root } = useThemePreview();
2root.setValue({ appearance: "dark" });

The hook throws outside a provider.

ThemePreviewSwitcher

An icon button that flips between light and dark. It follows resolved.appearance.

1<ThemePreviewSwitcher />

Prop

Type

Per-component radius

Components accept a radius prop with the theme's five values. It affects only that component and does not compound with the theme radius.

1<ThemePreview
2 isRoot={false}
3 defaultValue={{ radius: "large" }}
4 hasBackground={false}
5>
6 <Flex gap={3} align="center">
7 <Button>Large</Button>
8 {/* Overrides the theme without compounding */}
9 <Button radius="none">None</Button>
10 <Button radius="small">Small</Button>
11 <Button radius="full">Full</Button>
12 </Flex>
13</ThemePreview>

Available on Button, IconButton, Badge, Callout, Chip, Input, TextArea, Image, Avatar, and on the portalled parts 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 goes on the portalled part, not the root: <Popover.Content radius="none">.

Customization

Tokens

Every --rs-* declaration is wrapped in :where() and every theme element carries the rs-theme class, so one class selector overrides any token without !important:

1.rs-theme {
2 --rs-color-background-accent-emphasis: #6d28d9;
3 --rs-radius-3: 10px;
4}
5
6.marketing-page .rs-theme {
7 --rs-font-title: "Playfair Display", serif;
8}

Inline style works too:

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

Fonts

Three CSS variables, 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}

Import one stylesheet: @raystack/apsara/style.css includes the font imports, @raystack/apsara/style-no-fonts.css leaves them out for self-hosted fonts. The type scale is tuned for Inter, so another font may need its line heights and tracking adjusted.

Migrating from Theme

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 also gates persistence
Persistence at the root by defaultpersistKey is required to persist
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" });

Also

  • style="modern" | "traditional" becomes a radius level plus a font pair: defaultValue={{ radius: "large" }} with --rs-font-title and --rs-font-body set in CSS.
  • Image and Avatar use the shared five radius values. Image gains large. Avatar has no default radius and follows the theme; pass radius="full" to keep circles.
  • Tokens are no longer on <html>. Consumer CSS and hand-rolled portals outside the provider must move inside it.
  • useThemePreview throws outside a provider instead of returning a no-op.
  • persistKey ignores the bare theme name Theme stored, so a migrated user starts from the seed once. Use a new key if that matters.

API Reference

ThemePreview

Prop

Type

ThemeSettings

Prop

Type