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.
Usage
1import { ThemePreview } from "@raystack/apsara";23export 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.
| Setting | Values | Default | Attribute |
|---|---|---|---|
appearance | light, dark, system | system | data-theme |
accentColor | indigo, orange, mint | indigo | data-accent-color |
grayColor | gray, mauve, slate, sage, auto | auto | data-gray-color |
radius | none, small, medium, large, full | medium | data-radius |
scaling | 0.9, 0.95, 1, 1.05, 1.1 | 1 | data-scaling |
panelBackground | solid, translucent | solid | data-panel-background |
reducedMotion | true, false, system | system | data-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 <ThemePreview4 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 <ThemePreview4 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 <ThemePreview4 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 <ThemePreview4 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<Flex2 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 <ThemePreview13 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<ThemePreview2 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, medium14 </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<ThemePreview2 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 <ThemePreview14 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<ThemePreview2 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>1314 <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);34 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>1011 <ThemePreview12 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 persisted2<ThemePreview3 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 namespace2<ThemePreview persistKey="app-theme" />34// Only the appearance5<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.tsx2import { ThemePreview } from "@raystack/apsara";34export 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";23function AppearanceToggle() {4 const { resolved, setValue } = useThemePreview();5 const isDark = resolved.appearance === "dark";67 return (8 <button onClick={() => setValue({ appearance: isDark ? "light" : "dark" })}>9 Toggle10 </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<ThemePreview2 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}56.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:
| Token | Role |
|---|---|
--rs-font-body | Body text |
--rs-font-title | Headings |
--rs-font-mono | Monospace |
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.
| Removed | Replacement |
|---|---|
theme | value.appearance |
defaultTheme | defaultValue.appearance |
forcedTheme | value.appearance |
accentColor, grayColor as flat props | defaultValue.accentColor, defaultValue.grayColor |
style | radius plus the --rs-font-* tokens |
onThemeChange | onValueChange |
enableSystem | appearance: "system" |
enableColorScheme | Handled by the stylesheet |
themes, attribute, value as a name-to-attribute map | None. Arbitrary named themes are not supported |
ThemeProvider alias | ThemePreview |
useTheme().theme / .setTheme / .resolvedTheme / .systemTheme | value / setValue / resolved / systemAppearance |
useTheme().themes / .forcedTheme / .style / .scopes | None |
useTheme({ storageKey }) | useThemePreview().root |
storageKey | persistKey, which also gates persistence |
| Persistence at the root by default | persistKey is required to persist |
ThemeSwitcher | ThemePreviewSwitcher |
Before and after
1// Before2<Theme3 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>1213// After14<ThemePreview15 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 subtree2<Theme forcedTheme="dark">3 <Sidebar />4</Theme>56// After7<ThemePreview value={{ appearance: "dark" }}>8 <Sidebar />9</ThemePreview>
1// Before: flip the page theme from inside a scope2const { setTheme } = useTheme({ storageKey: "theme" });34// After5const { root } = useThemePreview();6root.setValue({ appearance: "dark" });
Also
style="modern" | "traditional"becomes a radius level plus a font pair:defaultValue={{ radius: "large" }}with--rs-font-titleand--rs-font-bodyset in CSS.ImageandAvataruse the shared five radius values.Imagegainslarge.Avatarhas no default radius and follows the theme; passradius="full"to keep circles.- Tokens are no longer on
<html>. Consumer CSS and hand-rolled portals outside the provider must move inside it. useThemePreviewthrows outside a provider instead of returning a no-op.persistKeyignores the bare theme nameThemestored, so a migrated user starts from the seed once. Use a new key if that matters.
API Reference
ThemePreview
Prop
Type
ThemeSettings
Prop
Type