diff --git a/apps/www/src/app/examples/scoped-theme/page.tsx b/apps/www/src/app/examples/scoped-theme/page.tsx new file mode 100644 index 000000000..6624cc565 --- /dev/null +++ b/apps/www/src/app/examples/scoped-theme/page.tsx @@ -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)' +}; + +const Page = () => { + const { theme, setTheme } = useTheme(); + const [scopeTheme, setScopeTheme] = useState('dark'); + const [calloutScopeTheme, setCalloutScopeTheme] = + useState('light'); + const GlobalIcon = theme === 'dark' ? Sun : Moon; + + return ( + + + + Scoped Theming + + + setTheme({ theme: theme === 'dark' ? 'light' : 'dark' }) + } + > + + + + + + } + > + + + Scoped box + + + setScopeTheme(scopeTheme === 'dark' ? 'light' : 'dark') + } + > + {scopeTheme === 'dark' ? : } + + + + This box themes itself via{' '} + data-theme="{scopeTheme}", independent of the + page. + + + + + + + + + } + > + + + Semantic colors in scope + + + setCalloutScopeTheme( + calloutScopeTheme === 'dark' ? 'light' : 'dark' + ) + } + > + {calloutScopeTheme === 'dark' ? : } + + + + Accent, success, danger, and attention tokens all re-resolve at the + scope. + + Accent — informational message + Success — operation completed + Danger — something went wrong + Attention — review before continuing + + + ); +}; + +export default Page; diff --git a/apps/www/src/content/docs/theme/overview/index.mdx b/apps/www/src/content/docs/theme/overview/index.mdx index 5d72b418b..82b5ce0b8 100644 --- a/apps/www/src/content/docs/theme/overview/index.mdx +++ b/apps/www/src/content/docs/theme/overview/index.mdx @@ -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 + + {/* Page is dark */} +
+ {/* This subtree renders with light tokens */} + +
+ +``` + +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"; + + + Dark scoped card + +``` + +`ThemeScope` writes `data-theme` (and optionally `data-accent-color`, `data-gray-color`, `data-style`) onto a wrapper element. By default it renders a `
`. Use the `render` prop to fuse the scope onto an element you already have, with no extra wrapping div: + +```tsx +}> + ... + ... + +``` + +### 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 + + + + + + Dark mint-on-slate card + +``` + +### 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'); + +}> + setScopeTheme(t => t === 'dark' ? 'light' : 'dark')}> + Toggle this scope + + ... + +``` + +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. + + + +### 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. diff --git a/apps/www/src/content/docs/theme/overview/props.ts b/apps/www/src/content/docs/theme/overview/props.ts index e76751474..2fc3fd153 100644 --- a/apps/www/src/content/docs/theme/overview/props.ts +++ b/apps/www/src/content/docs/theme/overview/props.ts @@ -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 `
`. 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; diff --git a/packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx b/packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx new file mode 100644 index 000000000..feee985c7 --- /dev/null +++ b/packages/raystack/components/theme-provider/__tests__/theme-scope.test.tsx @@ -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( + + inside + + ); + + const node = screen.getByTestId('scope'); + expect(node.tagName).toBe('DIV'); + expect(screen.getByText('inside')).toBeInTheDocument(); + }); + + it('writes data-theme when theme is provided', () => { + render(); + expect(screen.getByTestId('scope')).toHaveAttribute('data-theme', 'dark'); + }); + + it('omits data attributes when their props are not provided', () => { + render(); + + 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( + + ); + + 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( + + ); + + const node = screen.getByTestId('scope'); + expect(node).toHaveAttribute('id', 'my-scope'); + expect(node).toHaveClass('custom-class'); + }); + + it('passes through user-provided style', () => { + render( + + ); + + expect(screen.getByTestId('scope')).toHaveStyle({ background: 'red' }); + }); + }); + + describe('render prop', () => { + it('renders the provided element instead of a default div', () => { + render( + }> + inside + + ); + + const node = screen.getByTestId('scope'); + expect(node.tagName).toBe('SECTION'); + expect(screen.getByText('inside')).toBeInTheDocument(); + }); + + it('merges data attributes onto the rendered element', () => { + render( + } + /> + ); + + 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( + + } + /> + ); + + const node = screen.getByTestId('scope'); + expect(node).toHaveAttribute('id', 'my-section'); + expect(node).toHaveClass('base-class'); + expect(node).toHaveAttribute('data-theme', 'light'); + }); + }); +}); diff --git a/packages/raystack/components/theme-provider/index.tsx b/packages/raystack/components/theme-provider/index.tsx index f6ba57169..5972173fe 100644 --- a/packages/raystack/components/theme-provider/index.tsx +++ b/packages/raystack/components/theme-provider/index.tsx @@ -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. diff --git a/packages/raystack/components/theme-provider/theme-scope.tsx b/packages/raystack/components/theme-provider/theme-scope.tsx new file mode 100644 index 000000000..c028b7ee5 --- /dev/null +++ b/packages/raystack/components/theme-provider/theme-scope.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { mergeProps, useRender } from '@base-ui/react'; +import type { + AccentColor, + ColorScheme, + GrayColor, + StyleVariant +} from './types'; + +export interface ThemeScopeProps extends useRender.ComponentProps<'div'> { + /** Color scheme for this subtree. Sets `data-theme`; native UI (form + * controls, scrollbars) follows via the package's `color-scheme` rule. */ + theme?: ColorScheme; + /** Accent color for this subtree. Sets `data-accent-color`. */ + accentColor?: AccentColor; + /** Gray variant for this subtree. Sets `data-gray-color`. */ + grayColor?: GrayColor; + /** Style variant for this subtree. Sets `data-style`. */ + styleVariant?: StyleVariant; +} + +export function ThemeScope({ + theme, + accentColor, + grayColor, + styleVariant, + render, + ref, + ...props +}: ThemeScopeProps) { + return useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + 'data-theme': theme, + 'data-accent-color': accentColor, + 'data-gray-color': grayColor, + 'data-style': styleVariant + } as useRender.ComponentProps<'div'>, + props + ) + }); +} + +ThemeScope.displayName = 'ThemeScope'; diff --git a/packages/raystack/components/theme-provider/theme.tsx b/packages/raystack/components/theme-provider/theme.tsx index ae9c2bf93..239aa1bb2 100644 --- a/packages/raystack/components/theme-provider/theme.tsx +++ b/packages/raystack/components/theme-provider/theme.tsx @@ -10,10 +10,10 @@ import { useMemo, 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(undefined); @@ -31,7 +31,7 @@ export function ThemeProvider(props: ThemeProviderProps) { ThemeProvider.displayName = 'ThemeProvider'; -const defaultThemes = ['light', 'dark']; +const defaultThemes: string[] = [...COLOR_SCHEMES]; const Theme = ({ forcedTheme, diff --git a/packages/raystack/components/theme-provider/types.ts b/packages/raystack/components/theme-provider/types.ts index 897aad606..301ed2e3c 100644 --- a/packages/raystack/components/theme-provider/types.ts +++ b/packages/raystack/components/theme-provider/types.ts @@ -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[]; @@ -14,7 +24,7 @@ export interface UseThemeProps { /** If `enableSystem` is true and the active theme is "system", this returns whether the system preference resolved to "dark" or "light". Otherwise, identical to `theme` */ resolvedTheme?: string; /** If enableSystem is true, returns the System theme preference ("dark" or "light"), regardless what the active theme is */ - systemTheme?: "dark" | "light"; + systemTheme?: 'dark' | 'light'; } export interface ThemeProviderProps { @@ -33,17 +43,17 @@ export interface ThemeProviderProps { /** Default theme name (for v0.0.12 and lower the default was light). If `enableSystem` is false, the default theme is light */ defaultTheme?: string; /** HTML attribute modified based on the active theme. Accepts `class` and `data-*` (meaning any data attribute, `data-mode`, `data-color`, etc.) */ - attribute?: string | "class"; + attribute?: string | 'class'; /** Mapping of theme name to HTML attribute value. Object where key is the theme name and value is the attribute value */ value?: ValueObject; /** Nonce string to pass to the inline script for CSP headers */ 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; } diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index cd849b3b3..bd96f6071 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -74,6 +74,8 @@ export { TextArea } from './components/text-area'; export { ThemeProvider, ThemeProviderProps, + ThemeScope, + ThemeScopeProps, ThemeSwitcher, useTheme } from './components/theme-provider'; diff --git a/packages/raystack/styles/colors.css b/packages/raystack/styles/colors.css index 729fff03e..d8fc2940c 100644 --- a/packages/raystack/styles/colors.css +++ b/packages/raystack/styles/colors.css @@ -1,9 +1,10 @@ -/* - * Note: Use color variables from this file only. - * Avoid using color variables from other files in the v1/styles folder directly. +/* + * Note: Use color variables from this file only. + * Avoid using color variables from other files in the styles folder directly. */ -:root { +[data-theme='light'], +[data-theme='dark'] { /* Base Foreground Colors */ --rs-color-foreground-base-primary: var(--rs-neutral-12); --rs-color-foreground-base-secondary: var(--rs-neutral-11); @@ -108,7 +109,7 @@ --rs-color-foreground-danger-emphasis: var(--rs-danger-contrast); --rs-color-foreground-attention-emphasis: var(--rs-attention-contrast); --rs-color-foreground-success-emphasis: var(--rs-success-contrast); - + /* Visualization Colors */ /* Sky */ --rs-color-viz-sky-11: var(--rs-viz-sky-11); diff --git a/packages/raystack/styles/primitives/accent.css b/packages/raystack/styles/primitives/accent.css index 3f8a65da0..ae445015e 100644 --- a/packages/raystack/styles/primitives/accent.css +++ b/packages/raystack/styles/primitives/accent.css @@ -1,11 +1,11 @@ -/* - * Note: Do not use this file directly. - * It is used for generating the final colors file. +/* + * Note: Do not use this file directly. + * It is used for generating the final colors file. * All variables must be used from colors.css file only. */ /* [data-accent-color='indigo'] { */ -:root { +[data-theme='light'] { --rs-accent-1: oklch(0.9943 0.0013 286.38); --rs-accent-2: oklch(0.9823 0.0083 271.33); --rs-accent-3: oklch(0.9609 0.017 267.79); diff --git a/packages/raystack/styles/primitives/appearance.css b/packages/raystack/styles/primitives/appearance.css index 6c4640d4c..60b3af6b3 100644 --- a/packages/raystack/styles/primitives/appearance.css +++ b/packages/raystack/styles/primitives/appearance.css @@ -1,14 +1,26 @@ -/* - * Note: Do not use this file directly. - * It is used for generating the final colors file. +/* + * Note: Do not use this file directly. + * It is used for generating the final colors file. * All variables must be used from colors.css file only. */ -/* Light Theme Colors */ -:root { - /* Smooth theme switch transition */ +/* Smooth theme switch transition — applies regardless of which theme is active */ +[data-theme="light"], +[data-theme="dark"] { transition: background-color 0.4s ease, color 0.4s ease; +} + +/* Native UI (form controls, scrollbars, text selection) follows the scoped theme */ +[data-theme="light"] { + color-scheme: light; +} +[data-theme="dark"] { + color-scheme: dark; +} + +/* Light Theme Colors */ +[data-theme="light"] { /* Neutral Colors */ --rs-neutral-1: var(--rs-gray-1); --rs-neutral-2: var(--rs-gray-2); @@ -134,7 +146,7 @@ } /* Dark Theme Colors */ -html[data-theme="dark"] { +[data-theme="dark"] { /* Neutral Colors */ --rs-neutral-1: var(--rs-gray-1); --rs-neutral-2: var(--rs-gray-2); @@ -256,4 +268,4 @@ html[data-theme="dark"] { /* Overlay color */ --rs-overlay-1: oklch(1 0 0 / 0.302); /* 30% opacity */ -} \ No newline at end of file +} diff --git a/packages/raystack/styles/primitives/gray.css b/packages/raystack/styles/primitives/gray.css index 6c7d32884..66c7e901e 100644 --- a/packages/raystack/styles/primitives/gray.css +++ b/packages/raystack/styles/primitives/gray.css @@ -5,7 +5,8 @@ */ -[data-gray-color='gray'] { +[data-gray-color='gray'], +[data-theme='light'] { --rs-gray-1: oklch(0.9911 0 0); --rs-gray-2: oklch(0.9821 0 0); --rs-gray-3: oklch(0.9551 0 0); @@ -20,7 +21,8 @@ --rs-gray-12: oklch(0.2435 0 0); } -[data-gray-color='gray'][data-theme='dark'] { +[data-gray-color='gray'][data-theme='dark'], +[data-theme='dark'] { --rs-gray-1: oklch(0.1776 0 0); --rs-gray-2: oklch(0.2134 0 0); --rs-gray-3: oklch(0.252 0 0);