Skip to content
Open
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
135 changes: 135 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,135 @@
'use client';

import {
Button,
Callout,
Flex,
IconButton,
Text,
ThemeScope
} 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>

<ThemeScope
theme={scopeTheme}
render={
<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>
</ThemeScope>

<ThemeScope
theme={calloutScopeTheme}
render={
<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>
</ThemeScope>
</Flex>
);
};

export default Page;
80 changes: 80 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,83 @@ 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.

### `ThemeScope` component

For a typed convenience wrapper, use `ThemeScope`:

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

<ThemeScope theme="dark">
<Card>Dark scoped card</Card>
</ThemeScope>
```

`ThemeScope` writes `data-theme` (and optionally `data-accent-color`, `data-gray-color`, `data-style`) onto a wrapper element. By default it renders a `<div>`. Use the `render` prop to fuse the scope onto an element you already have, with no extra wrapping div:

```tsx
<ThemeScope theme="dark" render={<Flex direction="column" gap={5} />}>
<Heading>...</Heading>
<Text>...</Text>
</ThemeScope>
```

### 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
<ThemeScope accentColor="orange">
<Button color="accent">Orange accent in this region only</Button>
</ThemeScope>

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

### State management

`ThemeScope` 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');

<ThemeScope theme={scopeTheme} render={<Flex direction="column" />}>
<Toggle onClick={() => setScopeTheme(t => t === 'dark' ? 'light' : 'dark')}>
Toggle this scope
</Toggle>
<Card>...</Card>
</ThemeScope>
```

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

<auto-type-table path="./props.ts" name="ThemeScopeProps" />

### When to reach for `ThemeScope` 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 **`ThemeScope`** when you want typed props (`theme`, `accentColor`, etc.), defaults handled for you, and a single import that documents intent.
- Both produce the same DOM output when `ThemeScope` is given a `render` prop.
20 changes: 20 additions & 0 deletions apps/www/src/content/docs/theme/overview/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@ export type ThemeProviderProps = {
children?: React.ReactNode;
};

export type ThemeScopeProps = {
/** Color scheme for this subtree. Sets `data-theme` on the rendered element. */
theme?: 'light' | 'dark';

/** Accent color for this subtree. Sets `data-accent-color`. */
accentColor?: 'indigo' | 'orange' | 'mint';

/** Gray variant for this subtree. Sets `data-gray-color`. */
grayColor?: 'gray' | 'mauve' | 'slate';

/** Style variant for this subtree. Sets `data-style`. */
styleVariant?: 'modern' | 'traditional';

/** Element to render the scope on. Defaults to a `<div>`. Use this to fuse the scope onto an existing layout element and avoid an extra wrapper. */
render?: React.ReactElement;

/** React children rendered inside the scope. */
children?: React.ReactNode;
};

export type UseThemeProps = {
/** Current theme name ("light", "dark", or "system") */
theme?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { ThemeScope } from '../theme-scope';

describe('ThemeScope', () => {
describe('Default rendering', () => {
it('renders a div by default with the provided children', () => {
render(
<ThemeScope theme='dark' data-testid='scope'>
<span>inside</span>
</ThemeScope>
);

const node = screen.getByTestId('scope');
expect(node.tagName).toBe('DIV');
expect(screen.getByText('inside')).toBeInTheDocument();
});

it('writes data-theme when theme is provided', () => {
render(<ThemeScope theme='dark' data-testid='scope' />);
expect(screen.getByTestId('scope')).toHaveAttribute('data-theme', 'dark');
});

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

const node = screen.getByTestId('scope');
expect(node).not.toHaveAttribute('data-theme');
expect(node).not.toHaveAttribute('data-accent-color');
expect(node).not.toHaveAttribute('data-gray-color');
expect(node).not.toHaveAttribute('data-style');
});

it('writes every supported data attribute', () => {
render(
<ThemeScope
theme='light'
accentColor='orange'
grayColor='mauve'
styleVariant='traditional'
data-testid='scope'
/>
);

const node = screen.getByTestId('scope');
expect(node).toHaveAttribute('data-theme', 'light');
expect(node).toHaveAttribute('data-accent-color', 'orange');
expect(node).toHaveAttribute('data-gray-color', 'mauve');
expect(node).toHaveAttribute('data-style', 'traditional');
});

it('forwards arbitrary HTML attributes', () => {
render(
<ThemeScope
theme='dark'
id='my-scope'
className='custom-class'
data-testid='scope'
/>
);

const node = screen.getByTestId('scope');
expect(node).toHaveAttribute('id', 'my-scope');
expect(node).toHaveClass('custom-class');
});

it('passes through user-provided style', () => {
render(
<ThemeScope
theme='dark'
style={{ background: 'red' }}
data-testid='scope'
/>
);

expect(screen.getByTestId('scope')).toHaveStyle({ background: 'red' });
});
});

describe('render prop', () => {
it('renders the provided element instead of a default div', () => {
render(
<ThemeScope theme='dark' render={<section data-testid='scope' />}>
<span>inside</span>
</ThemeScope>
);

const node = screen.getByTestId('scope');
expect(node.tagName).toBe('SECTION');
expect(screen.getByText('inside')).toBeInTheDocument();
});

it('merges data attributes onto the rendered element', () => {
render(
<ThemeScope
theme='dark'
accentColor='mint'
render={<section data-testid='scope' />}
/>
);

const node = screen.getByTestId('scope');
expect(node).toHaveAttribute('data-theme', 'dark');
expect(node).toHaveAttribute('data-accent-color', 'mint');
});

it('preserves the rendered element’s own attributes alongside the merged ones', () => {
render(
<ThemeScope
theme='light'
render={
<section
id='my-section'
className='base-class'
data-testid='scope'
/>
}
/>
);

const node = screen.getByTestId('scope');
expect(node).toHaveAttribute('id', 'my-section');
expect(node).toHaveClass('base-class');
expect(node).toHaveAttribute('data-theme', 'light');
});
});
});
7 changes: 4 additions & 3 deletions packages/raystack/components/theme-provider/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { ThemeSwitcher } from "./switcher";
export { ThemeProvider, useTheme } from "./theme";
export { ThemeProviderProps } from "./types";
export { ThemeSwitcher } from './switcher';
export { ThemeProvider, useTheme } from './theme';
export { ThemeScope, type ThemeScopeProps } from './theme-scope';
export { ThemeProviderProps } from './types';
// Note: This themeProvider folder is a merge of old and the new themeProvider. Both old and the v1 folder contains the exact copy of themeProvider which was merged.
Loading
Loading