Surfaces
Theme scope
Re-theme a subtree at runtime: a different palette, material, shape or scheme for one card, section or preview.
Installation
pnpm dlx shadcn@latest add https://glasscn.app/r/theme-scope.jsonOr with the namespace set up: shadcn add @glasscn/theme-scope
Props
| Prop | Type | Default |
|---|---|---|
| palette / material / shape / motion / density / type | string | HueSpec | numberPreset names (or a hue spec for the palette) — composed with createGlassTheme. | — |
| fonts | { sans?, heading?, display?, mono? }Theme fonts by catalogue key (inter, fraunces, jetbrains-mono…) or raw CSS stacks. | — |
| loadFonts | booleanAdd one Google Fonts stylesheet for the theme's fonts. | false |
| theme | GlassThemeA finished theme instead, e.g. from createGlassTheme or decodeTheme. | — |
| scheme | "light" | "dark"Force a scheme for this subtree; omit to follow the page's .dark. | — |
Writes one sanitised, scoped <style> for its own subtree. Portalled content (dialogs, menus, tooltips) renders outside it — wrap the portal target, or theme the page instead.
Source
components/glass/theme-scope.tsx
import * as React from "react"
import { cn } from "cn"
import { createGlassTheme, fontStylesheetUrl, themeToCss, type CreateThemeOptions, type GlassTheme } from "@/lib/glass-theme"
/** shadcn's text colours per scheme — written when a scheme is forced, so a light scope reads right inside a dark page. */
const TEXT = {
light: { foreground: "oklch(0.145 0 0)", "card-foreground": "oklch(0.145 0 0)", "popover-foreground": "oklch(0.145 0 0)", "muted-foreground": "oklch(0.556 0 0)" },
dark: { foreground: "oklch(0.985 0 0)", "card-foreground": "oklch(0.985 0 0)", "popover-foreground": "oklch(0.985 0 0)", "muted-foreground": "oklch(0.708 0 0)" },
}
/**
* Re-themes everything inside it. Pass a finished theme or preset names:
*
* <ThemeScope palette="ocean" material="liquid" shape="sharp">…</ThemeScope>
* <ThemeScope theme={createGlassTheme({ palette: { hue: 200, harmony: "triadic" } })} scheme="dark">…</ThemeScope>
* <ThemeScope type="editorial" loadFonts>…</ThemeScope>
*
* Writes one scoped, sanitised <style> for its own subtree — light rules, dark rules
* (following the page's `.dark`), or a forced `scheme`. Works in Server Components.
*/
function ThemeScope({
theme,
palette,
material,
shape,
motion,
density,
type,
fonts,
scheme,
loadFonts = false,
className,
children,
...props
}: React.ComponentProps<"div"> &
Pick<CreateThemeOptions, "palette" | "material" | "shape" | "motion" | "density" | "type" | "fonts"> & {
theme?: GlassTheme
/** Force a scheme for this subtree; omit to follow the page. */
scheme?: "light" | "dark"
/** Load the theme's Google fonts (one stylesheet, deduplicated by React). Off: you load them. */
loadFonts?: boolean
}) {
const id = React.useId().replace(/[^a-zA-Z0-9_-]/g, "")
const resolved = theme ?? createGlassTheme({ palette, material, shape, motion, density, type, fonts })
const fontUrl = loadFonts ? fontStylesheetUrl(resolved) : null
const at = `[data-glass-scope="${id}"]`
const forced = scheme ? { ...resolved[scheme], ...TEXT[scheme] } : null
const css = forced
? themeToCss({ light: forced, dark: forced }, { selector: at, darkSelector: `.dark ${at}` })
: themeToCss(resolved, { selector: at, darkSelector: `.dark ${at}, ${at}.dark` })
return (
<div data-slot="theme-scope" data-glass-scope={id} className={cn("type-glass", scheme, className)} {...props}>
{fontUrl ? <link rel="stylesheet" href={fontUrl} precedence="glass-fonts" /> : null}
{/* a plain, in-place <style>: it must update on every change (a hoisted, keyed one would not) */}
<style>{css}</style>
{children}
</div>
)
}
export { ThemeScope }