Overview

Understanding the Apsara theming system and the Theme component.

Apsara provides a theming system built on CSS custom properties (tokens). Tokens are semantic variables that automatically resolve to appropriate values based on the active theme—so your UI adapts seamlessly when users switch between light and dark modes or when you change accent colors, without any code changes.

Installation

Wrap your application with the Theme component:

1import { Theme } from "@raystack/apsara";
2
3function App() {
4 return (
5 <Theme defaultTheme="system">
6 <YourApp />
7 </Theme>
8 );
9}

Customization

The Theme component accepts props to control the visual identity of your application. Combine style variants with accentColor and grayColor to create distinct aesthetics—from sharp and technical to warm and editorial. The defaultTheme prop controls light/dark mode, with system respecting the user's OS preference.

1// Clean, technical aesthetic
2<Theme style="modern" accentColor="indigo" grayColor="slate">
3
4// Warm, editorial feel
5<Theme style="traditional" accentColor="orange" grayColor="mauve">
6
7// Vibrant and fresh
8<Theme style="modern" accentColor="mint" grayColor="gray">

See API Reference for all available props and options.

Tokens

Tokens follow two naming patterns:

Semantic tokens — for context-aware values that adapt to theme:

1--rs-{category}-{property}-{variant}-{state}

Scale tokens — for numerical progressions:

1--rs-{category}-{step}

Examples:

  • --rs-color-foreground-base-primary — primary text color
  • --rs-color-background-accent-emphasis — accent button background
  • --rs-space-5 — 16px spacing
  • --rs-radius-3 — medium border radius
  • --rs-shadow-lifted — elevated shadow

Using tokens in CSS:

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}

Token Categories:

  • Colors — foreground, background, border, and overlay colors
  • Spacing — consistent scale from 2px to 120px
  • Radius — border radius that adapts to style variants
  • Typography — font families, sizes, weights, and line heights
  • Effects — shadows and blur for depth and elevation

API Reference

Theme

The Theme component wraps your application and manages theme state. It handles persisting the user's preference to localStorage, syncing with system preferences, and injecting the appropriate CSS variables into the document.

Prop

Type

ThemeProvider is a deprecated alias for Theme. New code should import Theme; the old name is kept for backward compatibility and will be removed in a future major release.

useTheme

The useTheme hook provides access to the current theme state and methods to change it. Use this to build theme toggles, read the resolved theme for conditional rendering, or sync with external systems.

1import { useTheme } from "@raystack/apsara";
2
3function ThemeToggle() {
4 const { theme, setTheme, resolvedTheme } = useTheme();
5
6 return (
7 <button onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}>
8 Toggle theme
9 </button>
10 );
11}

Prop

Type

Framework Integration

HTML AttributesTheme sets data attributes on the document element for CSS targeting:

  • data-theme — current color scheme (light | dark)
  • data-style — active style variant (modern | traditional)
  • data-accent-color — active accent color (indigo | orange | mint)
  • data-gray-color — active gray variant (gray | mauve | slate)

SSR & Flash PreventionTheme includes an inline script that runs before React hydration to prevent flash of incorrect theme. For SSR frameworks, include the provider in your root layout:

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" suppressHydrationWarning>
7 <body>
8 <Theme>{children}</Theme>
9 </body>
10 </html>
11 );
12}

The suppressHydrationWarning is required because the theme script modifies the HTML element before React hydrates.

Scoped Theming

Themes are not limited to the document root. Any element with a data-theme attribute creates an isolated theme scope — descendants resolve every design token from the nearest scoped ancestor. This enables theme preview cards, split-screen comparisons, and dark sidebars in light apps without any extra plumbing.

Bare attribute

Because scoping is implemented in CSS, you can opt in by simply setting the attribute on any element:

1<html data-theme="dark">
2 {/* Page is dark */}
3 <div data-theme="light">
4 {/* This subtree renders with light tokens */}
5 <Button>Light button inside dark page</Button>
6 </div>
7</html>

The package's stylesheet handles the rest: every --rs-color-* token, color-scheme for native form controls and scrollbars, and the smooth transition during theme switches all follow the scoped attribute.

Nested Theme

Render a Theme inside another Theme and the inner one switches to scope mode.

For a typed convenience wrapper, nest Theme:

1import { Theme } from "@raystack/apsara";
2
3<Theme forcedTheme="dark">
4 <Card>Dark scoped card</Card>
5</Theme>

When Theme is rendered inside another Theme, it switches to scope mode: it writes data-theme (and optionally data-accent-color, data-gray-color, data-style) onto a wrapper <div> rather than the document root. The outer provider's state remains the source of truth for useTheme().

Inheritance rules

Two rules cover every case:

  1. Each prop inherits independently. Any prop you don't pass to a nested Theme is inherited from the parent. Any prop you do pass overrides only that field — the rest still inherits.
  2. useTheme() inside a scope talks to that scope only. Calling setTheme() updates the nearest scope, never propagates outward. To target a specific outer scope (e.g., the root), use useTheme({ storageKey }) — see Targeting a specific scope.

Activating a scope

A nested <Theme> becomes an active scope (owns state, renders a wrapper, provides its own context) when you pass at least one of: forcedTheme, defaultTheme, accentColor, grayColor, style, or storageKey. A bare <Theme> with no props is a no-op pass-through — children render with the parent's context.

If you want a section to act as a scope but don't need to override any specific token, pass defaultTheme (seeds an initial scope theme):

1<Theme defaultTheme="dark">
2 {/* This is now a stateless scope. setTheme inside updates this scope only. */}
3</Theme>

Composition examples

The cases below assume a configured page-level Theme and progressively richer nested overrides.

Both fully configured — independent states

1<Theme defaultTheme="dark" accentColor="orange" grayColor="mauve" style="traditional">
2 <Theme defaultTheme="light" accentColor="mint" grayColor="slate" style="modern">
3 <Card />
4 </Theme>
5</Theme>
  • Page: dark + orange + mauve + traditional. Persisted under storageKey="theme".
  • Scope: light + mint + slate + modern. Owns its state in memory (no storageKey, so not persisted).
  • Card renders with the scope's values.
  • Toggling the page does not move the scope; toggling the scope does not move the page.

Partial override — single prop

1<Theme defaultTheme="dark" accentColor="orange" grayColor="mauve" style="traditional">
2 <Theme accentColor="mint">
3 <Card />
4 </Theme>
5</Theme>

What Card sees:

FieldSourceValue
resolvedThemeinherited from pagedark
accentColorown (scope)mint
grayColorinheritedmauve
styleinheritedtraditional
  • The wrapper <div> gets data-theme="dark" data-accent-color="mint" data-gray-color="mauve" data-style="traditional" so CSS rules like [data-accent-color='mint'][data-theme='dark'] match correctly.
  • If the page theme later flips to light, the scope follows (still inheriting theme). Accent stays mint.
  • If the scope's own setTheme() is called, the scope decouples from the page for theme only; non-overridden fields keep inheriting.

Display-locked region — forcedTheme

1<Theme defaultTheme="dark">
2 <Theme forcedTheme="light">
3 <PreviewCard />
4 </Theme>
5</Theme>
  • PreviewCard and its descendants always render with the light theme, regardless of any toggle.
  • Accent / gray / style still inherit from the page.
  • setTheme() inside still updates the scope's stored value, but forcedTheme always wins for display.

Persistent independent island — storageKey

1<Theme defaultTheme="dark">
2 <Theme storageKey="sidebar-theme" defaultTheme="dark">
3 <Sidebar>
4 <ScopeToggle /> {/* setTheme here updates and persists the sidebar's own theme */}
5 </Sidebar>
6 </Theme>
7</Theme>
  • Two independent persisted entries: localStorage["theme"] (page) and localStorage["sidebar-theme"] (sidebar).
  • Reloading the page restores both to their last-saved values.
  • A toggle inside the sidebar flips only the sidebar.

Empty nested — no scope created

1<Theme defaultTheme="dark">
2 <Theme> {/* no props → no-op pass-through */}
3 <Card />
4 </Theme>
5</Theme>
  • The inner Theme does nothing: no wrapper, no new context.
  • useTheme() inside Card returns the page's state.
  • setTheme() inside Card flips the page.

Persistent scope

Pass storageKey to a nested Theme and it becomes stateful, persisting the scope's theme to localStorage. Descendants read and update it through the same useTheme() hook — it returns the nearest provider's state, so inside a persistent scope it returns the scope's theme and setter:

1import { Theme, useTheme } from "@raystack/apsara";
2
3function ScopeToggle() {
4 const { theme, setTheme } = useTheme();
5
6 return (
7 <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
8 Toggle (currently {theme ?? "inherited"})
9 </button>
10 );
11}
12
13<Theme storageKey="dashboard-theme" defaultTheme="dark">
14 <ScopeToggle />
15 <Dashboard />
16</Theme>

Behavior:

  • On mount, the scope reads localStorage[storageKey]. If present, that value wins. Otherwise the scope uses defaultTheme. If neither is set, the scope inherits from its parent (no data-theme on the wrapper).
  • Inside the scope, useTheme() returns layered state: scope-owned fields (theme, setTheme) come from the scope; the rest (themes, systemTheme, etc.) are inherited from the root.
  • setTheme(value) updates state and writes to the scope's localStorage key.
  • setTheme(undefined) clears the storage entry and re-inherits from the parent.
  • Changes from other tabs propagate automatically via the storage event.
  • forcedTheme, if passed, wins over storage for display but is not persisted — it's a developer override, not a user choice.

Gotchas:

  • Use a distinct storageKey per scope. Multiple scopes sharing one key is undefined behavior within the same tab.
  • There is no FOUC prevention for nested scopes. On reload, the scope renders with defaultTheme (or inherits) for one paint, then snaps to the saved value once React hydrates. For above-the-fold scopes you'll see a brief flash. The root provider's inline script protects <html> only; per-scope inline scripts are not emitted in this version.

Targeting a specific scope

useTheme() always talks to the nearest scope. To target a specific outer scope (typically the root, so a deep button can flip the whole page), pass its storageKey:

1import { useTheme } from "@raystack/apsara";
2
3function PageThemeButton() {
4 // Reaches past nearer scopes to the root (whose default storageKey is "theme").
5 const { theme, setTheme } = useTheme({ storageKey: "theme" });
6 return (
7 <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
8 Flip page
9 </button>
10 );
11}
  • If a matching scope is found, the hook returns its theme + setTheme; the rest of the fields still reflect the nearest scope.
  • If no scope with that storageKey exists in the ancestor tree, the hook falls back to the nearest scope (same as calling useTheme() with no argument).

Cheat sheet

What you wantWhat to pass on the nested Theme
Section is purely styled, no own stateDon't nest. There's nothing to scope.
Section has its own theme, in-memory onlydefaultTheme="dark" (any value)
Section is locked to a theme regardless of togglesforcedTheme="dark"
Section overrides only accent / gray / styleJust pass those props; theme inherits
Section persists its own theme across reloadsstorageKey="some-key" (+ optional defaultTheme)
Flip the page theme from inside a scopeuseTheme({ storageKey: "theme" }).setTheme(…)

When to reach for a nested provider vs. the bare attribute

  • Use the bare data-theme attribute when you're already rendering a custom element and don't want another wrapper. The CSS handles everything — components inside will theme correctly.
  • Use a nested Theme when you want typed props (forcedTheme, accentColor, etc.), automatic inheritance of unspecified fields, and useTheme() integration.