Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions apps/www/src/app/examples/scoped-theme/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
'use client';

import {
Button,
Callout,
Flex,
IconButton,
Text,
ThemeProvider
} from '@raystack/apsara';
import { Moon, Sun } from 'lucide-react';
import { useState } from 'react';
import { useTheme } from '@/components/theme';

type ScopeTheme = 'light' | 'dark';

const panelStyle = {
minWidth: 320,
padding: 'var(--rs-space-7)',
borderRadius: 'var(--rs-space-3)',
border: '1px solid var(--rs-color-border-base-primary)',
backgroundColor: 'var(--rs-color-background-base-primary)'
};
Comment on lines +17 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a radius token for borderRadius here.

borderRadius is pulling from the spacing scale. That makes the example teach the wrong token family and can drift if spacing and radius scales stop lining up.

Suggested fix
 const panelStyle = {
   minWidth: 320,
   padding: 'var(--rs-space-7)',
-  borderRadius: 'var(--rs-space-3)',
+  borderRadius: 'var(--rs-radius-3)',
   border: '1px solid var(--rs-color-border-base-primary)',
   backgroundColor: 'var(--rs-color-background-base-primary)'
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const panelStyle = {
minWidth: 320,
padding: 'var(--rs-space-7)',
borderRadius: 'var(--rs-space-3)',
border: '1px solid var(--rs-color-border-base-primary)',
backgroundColor: 'var(--rs-color-background-base-primary)'
};
const panelStyle = {
minWidth: 320,
padding: 'var(--rs-space-7)',
borderRadius: 'var(--rs-radius-3)',
border: '1px solid var(--rs-color-border-base-primary)',
backgroundColor: 'var(--rs-color-background-base-primary)'
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/www/src/app/examples/scoped-theme/page.tsx` around lines 17 - 23, The
panelStyle object uses a spacing token for borderRadius (borderRadius:
'var(--rs-space-3)') which teaches the wrong token family; update the
borderRadius value to use the appropriate radius token (e.g., replace
var(--rs-space-3) with the project's radius token such as var(--rs-radius-3) or
the correct --rs-radius-<n>), keeping the rest of panelStyle unchanged so the
example demonstrates the proper radius token usage.


const Page = () => {
const { theme, setTheme } = useTheme();
const [scopeTheme, setScopeTheme] = useState<ScopeTheme>('dark');
const [calloutScopeTheme, setCalloutScopeTheme] =
useState<ScopeTheme>('light');
const GlobalIcon = theme === 'dark' ? Sun : Moon;

return (
<Flex
direction='column'
gap={9}
style={{
minHeight: '100vh',
padding: 'var(--rs-space-11)',
backgroundColor: 'var(--rs-color-background-base-primary)'
}}
>
<Flex justify='between' align='center'>
<Text size={6} weight={500}>
Scoped Theming
</Text>
<IconButton
aria-label='Toggle page theme'
size={3}
onClick={() =>
setTheme({ theme: theme === 'dark' ? 'light' : 'dark' })
}
>
<GlobalIcon />
</IconButton>
</Flex>

<ThemeProvider forcedTheme={scopeTheme}>
<Flex
direction='column'
gap={5}
style={{ ...panelStyle, alignSelf: 'flex-start' }}
>
<Flex justify='between' align='center' gap={5}>
<Text size={4} weight={500}>
Scoped box
</Text>
<IconButton
aria-label='Toggle scope theme'
size={3}
onClick={() =>
setScopeTheme(scopeTheme === 'dark' ? 'light' : 'dark')
}
>
{scopeTheme === 'dark' ? <Sun /> : <Moon />}
</IconButton>
</Flex>
<Text size={3} variant='secondary'>
This box themes itself via{' '}
<code>data-theme=&quot;{scopeTheme}&quot;</code>, independent of the
page.
</Text>
<Flex gap={3}>
<Button variant='solid' color='accent'>
Solid
</Button>
<Button variant='outline' color='neutral'>
Outline
</Button>
</Flex>
</Flex>
</ThemeProvider>

<ThemeProvider forcedTheme={calloutScopeTheme}>
<Flex
direction='column'
gap={4}
style={{ ...panelStyle, alignSelf: 'flex-start' }}
>
<Flex justify='between' align='center' gap={5}>
<Text size={4} weight={500}>
Semantic colors in scope
</Text>
<IconButton
aria-label='Toggle callout scope theme'
size={3}
onClick={() =>
setCalloutScopeTheme(
calloutScopeTheme === 'dark' ? 'light' : 'dark'
)
}
>
{calloutScopeTheme === 'dark' ? <Sun /> : <Moon />}
</IconButton>
</Flex>
<Text size={3} variant='secondary'>
Accent, success, danger, and attention tokens all re-resolve at the
scope.
</Text>
<Callout type='accent'>Accent — informational message</Callout>
<Callout type='success'>Success — operation completed</Callout>
<Callout type='alert'>Danger — something went wrong</Callout>
<Callout type='attention'>
Attention — review before continuing
</Callout>
</Flex>
</ThemeProvider>
</Flex>
);
};

export default Page;
70 changes: 70 additions & 0 deletions apps/www/src/content/docs/theme/overview/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,73 @@ export default function RootLayout({ children }) {
```

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:

```tsx
<html data-theme="dark">
{/* Page is dark */}
<div data-theme="light">
{/* This subtree renders with light tokens */}
<Button>Light button inside dark page</Button>
</div>
</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 `ThemeProvider`

For a typed convenience wrapper, nest `ThemeProvider`:

```tsx
import { ThemeProvider } from "@raystack/apsara";

<ThemeProvider forcedTheme="dark">
<Card>Dark scoped card</Card>
</ThemeProvider>
```

When `ThemeProvider` is rendered inside another `ThemeProvider`, 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()`.

### Combining with accent and gray overrides

A scope can override accent or gray independently of theme. This is useful for highlighting a section without changing its color scheme:

```tsx
<ThemeProvider accentColor="orange">
<Button color="accent">Orange accent in this region only</Button>
</ThemeProvider>

<ThemeProvider forcedTheme="dark" accentColor="mint" grayColor="slate">
<Card>Dark mint-on-slate card</Card>
</ThemeProvider>
```

### State management

Nested `ThemeProvider` is stateless — the consumer owns the theme value. For an interactive scope, drive it with React state:

```tsx
const [scopeTheme, setScopeTheme] = useState<'light' | 'dark'>('dark');

<ThemeProvider forcedTheme={scopeTheme}>
<Toggle onClick={() => setScopeTheme(t => t === 'dark' ? 'light' : 'dark')}>
Toggle this scope
</Toggle>
<Card>...</Card>
</ThemeProvider>
```

If you need persistence across page loads, manage it yourself with `localStorage` and a `useEffect`. Nested providers deliberately avoid touching storage to keep them pure and to avoid disambiguation issues when multiple scopes share a page.

### 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 `ThemeProvider`** when you want typed props (`forcedTheme`, `accentColor`, etc.) and a single import that documents intent.
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ThemeProvider } from '../theme';

// jsdom doesn't ship these; the root ThemeProvider needs them on mount.
Object.defineProperty(window, 'localStorage', {
value: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
}
});

Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
});

// Nested usage: ThemeProvider renders a scoped wrapper with data-* attrs.
describe('ThemeProvider (scoped)', () => {
it('renders a div wrapper with children when nested', () => {
render(
<ThemeProvider>
<ThemeProvider forcedTheme='dark'>
<span data-testid='child'>inside</span>
</ThemeProvider>
</ThemeProvider>
);

const child = screen.getByTestId('child');
expect(child.parentElement?.tagName).toBe('DIV');
expect(screen.getByText('inside')).toBeInTheDocument();
});

it('writes data-theme on the scoped wrapper', () => {
render(
<ThemeProvider>
<ThemeProvider forcedTheme='dark'>
<span data-testid='child' />
</ThemeProvider>
</ThemeProvider>
);

expect(screen.getByTestId('child').parentElement).toHaveAttribute(
'data-theme',
'dark'
);
});

it('omits data attributes when their props are not provided', () => {
render(
<ThemeProvider>
<ThemeProvider>
<span data-testid='child' />
</ThemeProvider>
</ThemeProvider>
);

const wrapper = screen.getByTestId('child').parentElement!;
expect(wrapper).not.toHaveAttribute('data-theme');
expect(wrapper).not.toHaveAttribute('data-accent-color');
expect(wrapper).not.toHaveAttribute('data-gray-color');
expect(wrapper).not.toHaveAttribute('data-style');
});

it('writes every supported data attribute', () => {
render(
<ThemeProvider>
<ThemeProvider
forcedTheme='light'
accentColor='orange'
grayColor='mauve'
style='traditional'
>
<span data-testid='child' />
</ThemeProvider>
</ThemeProvider>
);

const wrapper = screen.getByTestId('child').parentElement!;
expect(wrapper).toHaveAttribute('data-theme', 'light');
expect(wrapper).toHaveAttribute('data-accent-color', 'orange');
expect(wrapper).toHaveAttribute('data-gray-color', 'mauve');
expect(wrapper).toHaveAttribute('data-style', 'traditional');
});
});
31 changes: 25 additions & 6 deletions packages/raystack/components/theme-provider/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import {
createContext,
Fragment,
memo,
useCallback,
useContext,
Expand All @@ -11,10 +10,10 @@ import {
useRef,
useState
} from 'react';

import type { ThemeProviderProps, UseThemeProps } from './types';
import { COLOR_SCHEMES } from './types';

const colorSchemes = ['light', 'dark'];
const colorSchemes: string[] = [...COLOR_SCHEMES];
const MEDIA = '(prefers-color-scheme: dark)';
const isServer = typeof window === 'undefined';
const ThemeContext = createContext<UseThemeProps | undefined>(undefined);
Expand All @@ -25,14 +24,34 @@ export const useTheme = () => useContext(ThemeContext) ?? defaultContext;
export function ThemeProvider(props: ThemeProviderProps) {
const context = useContext(ThemeContext);

// Ignore nested context providers, just passthrough children
if (context) return <Fragment>{props.children}</Fragment>;
// Nested usage: scoped subtree. Render a wrapper element that overrides
// theme tokens locally via `data-*` attributes; the parent provider's
// global state remains the source of truth for descendants reading
// `useTheme()`.
if (context) return <Scoped {...props} />;
return <Theme {...props} />;
}

ThemeProvider.displayName = 'ThemeProvider';

const defaultThemes = ['light', 'dark'];
const Scoped = ({
forcedTheme,
accentColor,
grayColor,
style,
children
}: ThemeProviderProps) => (
<div
data-theme={forcedTheme}
data-accent-color={accentColor}
data-gray-color={grayColor}
data-style={style}
>
{children}
</div>
);

const defaultThemes: string[] = [...COLOR_SCHEMES];

const Theme = ({
forcedTheme,
Expand Down
22 changes: 16 additions & 6 deletions packages/raystack/components/theme-provider/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ interface ValueObject {
[themeName: string]: string;
}

export const COLOR_SCHEMES = ['light', 'dark'] as const;
export const ACCENT_COLORS = ['indigo', 'orange', 'mint'] as const;
export const GRAY_COLORS = ['gray', 'mauve', 'slate'] as const;
export const STYLE_VARIANTS = ['modern', 'traditional'] as const;

export type ColorScheme = (typeof COLOR_SCHEMES)[number];
export type AccentColor = (typeof ACCENT_COLORS)[number];
export type GrayColor = (typeof GRAY_COLORS)[number];
export type StyleVariant = (typeof STYLE_VARIANTS)[number];

export interface UseThemeProps {
/** List of all available theme names */
themes: string[];
Expand Down Expand Up @@ -40,12 +50,12 @@ export interface ThemeProviderProps {
nonce?: string;
/** React children to be rendered within the ThemeProvider */
children?: React.ReactNode;
/** Style variant of the theme, either 'modern' or 'traditional'. Affects the radius and font properties. */
style?: 'modern' | 'traditional';
/** Accent color for the theme, options are 'indigo', 'orange', or 'mint' */
accentColor?: 'indigo' | 'orange' | 'mint';
/** Gray color variant for the theme, options are 'gray', 'mauve', or 'slate' */
grayColor?: 'gray' | 'mauve' | 'slate';
/** Style variant of the theme. Affects the radius and font properties. */
style?: StyleVariant;
/** Accent color for the theme. */
accentColor?: AccentColor;
/** Gray color variant for the theme. */
grayColor?: GrayColor;
/** Called when the active theme changes. `resolvedTheme` is the actual applied theme (`'light'`/`'dark'` when `theme` is `'system'`). Not fired on initial mount. */
onThemeChange?: (theme: string, resolvedTheme: string) => void;
}
Loading
Loading