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";23export 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.
| 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 <Theme4 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 <Theme4 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 <Theme4 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 <Theme4 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 <Theme4 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.Trigger13 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<Theme2 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<Theme2 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 <Theme14 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<Theme2 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<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);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 <Theme12 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<Theme3 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 namespace2<Theme persistKey="app-theme" />34// Only the appearance5<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.tsx2import { Theme } from "@raystack/apsara";34export 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";23function AppearanceToggle() {4 const { resolved, setValue } = useTheme();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 } = 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<Theme2 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 scope10 </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-emphasis2--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}56.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:
| 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.
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