Overview

Theming with the Theme component. Tokens, seven settings, nested scopes, and portals that follow.

1<ThemePanelDemo />

Apsara's theming is built on CSS custom properties, called tokens. Theme mounts them on a real element, so the root theme, a nested scope and a portalled popup are all 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 { Theme } from "@raystack/apsara";
2
3export default function App() {
4 return (
5 <Theme persistKey="app-theme">
6 <YourApp />
7 </Theme>
8 );
9}

Tokens live on the element Theme 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 <Theme
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 <Theme
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 </Theme>
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 <Theme
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 </Theme>
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 <Theme
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 </Theme>
14 ))}
15</Flex>

Panel background

Overlay surfaces are opaque by default. translucent blurs what is behind dialogs, drawers, menus, popovers, selects, tooltips and toasts, and tints them with it: a 70% wash of the surface in light, and in dark a faint lift that lets the blur carry the panel.

1<Flex gap={7} align="start" style={{ width: "100%" }}>
2 {["solid", "translucent"].map((panelBackground) => (
3 <Theme
4 key={panelBackground}
5 isRoot={false}
6 defaultValue={{ panelBackground }}
7 hasBackground={false}
8 style={{ flex: 1, minWidth: 0 }}
9 >
10 <Flex direction="column" gap={4} align="start">
11 <Popover>
12 <Popover.Trigger
13 render={<Button variant="outline">{panelBackground}</Button>}
14 />
15 <Popover.Content>

A translucent panel only reads as translucent against something other than itself. Over a plain page, which is the colour the panel is drawn from, it looks identical to solid.

Reduced motion

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

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

Nesting

A nested Theme inherits every key it does not set.

1<Theme
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<Theme
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 <Theme
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<Theme
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<Theme isRoot={false} defaultValue={{ appearance: "dark" }}>
2 <Widget />
3</Theme>

render

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

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

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.

1function 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 <Theme
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<Theme
3 value={{ appearance: appearanceFromCookie }}
4 defaultValue={{ accentColor: "mint" }}
5 persistKey="app-theme"
6>
7 <App />
8</Theme>

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<Theme persistKey="app-theme" />
3
4// Only the appearance
5<Theme 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 { Theme } from "@raystack/apsara";
3
4export default function RootLayout({ children }) {
5 return (
6 <html lang="en">
7 <body>
8 <Theme persistKey="app-theme">{children}</Theme>
9 </body>
10 </html>
11 );
12}

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

useTheme

1import { useTheme } from "@raystack/apsara";
2
3function AppearanceToggle() {
4 const { resolved, setValue } = useTheme();
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 } = useTheme();
2root.setValue({ appearance: "dark" });

The hook throws outside a provider.

ThemeSwitcher

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

1<Theme
2 isRoot={false}
3 defaultValue={{ appearance: "light" }}
4 style={{ padding: "var(--rs-space-5)", borderRadius: "var(--rs-radius-4)" }}
5>
6 <Flex gap={3} align="center">
7 <ThemeSwitcher />
8 <Text size="small" variant="secondary">
9 Flips this scope
10 </Text>
11 </Flex>
12</Theme>

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<Theme isRoot={false} defaultValue={{ radius: "large" }} hasBackground={false}>
2 <Flex gap={3} align="center">
3 <Button>Large</Button>
4 {/* Overrides the theme without compounding */}
5 <Button radius="none">None</Button>
6 <Button radius="small">Small</Button>
7 <Button radius="full">Full</Button>
8 </Flex>
9</Theme>

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. Menu.SubmenuContent and ContextMenu.SubContent take it too. It goes on the portalled part, not the root: <Popover.Content radius="none">.

Tokens

Tokens are named by what they mean, not what they look like. Semantic tokens carry a context-aware value; scale tokens carry a step in a numeric progression.

1--rs-{category}-{property}-{variant}-{state} --rs-color-background-accent-emphasis
2--rs-{category}-{step} --rs-space-5, --rs-radius-3
1.custom-card {
2 background: var(--rs-color-background-base-secondary);
3 border: 1px solid var(--rs-color-border-base-primary);
4 border-radius: var(--rs-radius-4);
5 padding: var(--rs-space-5);
6 box-shadow: var(--rs-shadow-feather);
7}

The full reference is split by category: colors, typography, spacing, radius, effects and icons.

Overriding

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<Theme 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.

API Reference

Theme

Prop

Type

Theme also takes an icons prop, which replaces the drawings inside Apsara's components and sets the props every icon receives. It is documented on Icons.

ThemeSettings

Prop

Type