Themes
stableRuntime theming via CSS custom properties. Switch themes and color modes with zero flash, full TypeScript support, SSR compatibility, and automatic localStorage persistence.
How it works
1. Tokens as CSS vars
ThemeProvider writes --color-* variables on <html> whenever theme or mode changes.
2. Tailwind reads vars
The Tailwind preset maps every utility class (bg-primary, text-muted-foreground, etc.) to the corresponding CSS variable.
3. Components stay static
Components use only Tailwind classes — they never need to know which theme is active.
slate
Cool blue-slate — the default. Professional, clean, high contrast.
zinc
Pure neutral gray. Minimal and versatile for any brand color.
rose
Bold rose primary on a neutral base. Energetic and modern.
structyl
Full MUI-inspired palette with indigo primary and rich semantic tokens.
Setup
1. Install
pnpm add @structyl/themes2. Wrap your app
Place ThemeProvider at the root of your app — above any component that reads theme tokens or calls useTheme.
// app/layout.tsx (Next.js App Router)
import { ThemeProvider } from '@structyl/themes';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
defaultTheme="slate" // which color palette to use
defaultMode="system" // 'light' | 'dark' | 'system'
storageKey="my-app-theme" // localStorage key (false to disable)
enableTransitions={true} // smooth CSS transitions on theme change
>
{children}
</ThemeProvider>
</body>
</html>
);
}suppressHydrationWarning to <html> — the ThemeProvider writes data-theme and data-mode attributes that would otherwise cause a hydration mismatch warning.3. Add the Tailwind preset
The preset maps every theme token to a Tailwind utility class so you can writebg-primary instead ofbg-[hsl(var(--color-primary))].
// tailwind.config.ts
import type { Config } from 'tailwindcss';
import structylPreset from '@structyl/styled/tailwind-preset'; // or your own
export default {
presets: [structylPreset],
content: ['./src/**/*.{ts,tsx}', './app/**/*.{ts,tsx}'],
} satisfies Config;Theme switching
Call setTheme(name) from useTheme() to switch the active palette at runtime. All CSS variables update instantly — no page reload needed.
import { useTheme } from '@structyl/themes';
function ThemePicker() {
const { theme, setTheme, themes } = useTheme();
return (
<div className="flex gap-2">
{themes.map(name => (
<button
key={name}
onClick={() => setTheme(name)}
className={cn(
'rounded-lg px-3 py-1.5 text-sm font-medium capitalize transition-colors',
theme === name
? 'bg-primary text-primary-foreground'
: 'border border-border hover:bg-muted/60',
)}
>
{name}
</button>
))}
</div>
);
}Live demo — try switching themes and modes
Theme
Mode
Team members
Alice Chen
Engineer
Bob Smith
Designer
Carol Wu
Product
Current: theme="slate" resolvedMode="light"
Dark / light mode
Each theme ships a complete light and dark token set. mode can be 'light', 'dark', or 'system' (follows prefers-color-scheme). The resolved mode is always either 'light' or 'dark'.
import { useTheme } from '@structyl/themes';
import { Sun, Moon, Monitor } from '@structyl/icons';
function ModeToggle() {
const { mode, setMode, resolvedMode } = useTheme();
return (
<button
onClick={() => setMode(resolvedMode === 'dark' ? 'light' : 'dark')}
aria-label="Toggle color mode"
>
{resolvedMode === 'dark' ? <Sun /> : <Moon />}
</button>
);
}
// Full three-way picker (light / dark / system)
function ModePicker() {
const { setMode, resolvedMode } = useTheme();
return (
<>
<button onClick={() => setMode('light')}><Sun /> Light</button>
<button onClick={() => setMode('dark')}><Moon /> Dark</button>
<button onClick={() => setMode('system')}><Monitor /> System</button>
</>
);
}Live demo
resolvedMode = "light"
Token palette
Every token is a CSS custom property on :root. The values below update live as you switch themes above.
Base
bg
--color-bg
bg-bgfg
--color-fg
text-fgcard
--color-card
bg-cardmuted
--color-muted
bg-mutedaccent
--color-accent
bg-accentBrand
primary
--color-primary
bg-primaryprimary-fg
--color-primary-fg
text-primary-foregroundmuted-fg
--color-muted-fg
text-muted-foregroundborder
--color-border
border-borderSemantic
success
--color-success
text-successwarning
--color-warning
text-warningdestructive
--color-destructive
text-destructiveToken reference
All tokens follow the naming convention --color-{name} and accept HSL channel values (H S% L%) so Tailwind can apply opacity modifiers like bg-primary/50.
Base surface tokens
Backgrounds and foregrounds for the page, cards, and overlays.
| CSS variable | Tailwind class | Usage |
|---|---|---|
| --color-bg | bg-bg | Page background |
| --color-fg | text-fg | Default text color |
| --color-card | bg-card | Card / panel surface |
| --color-card-fg | text-card-foreground | Text on card surfaces |
| --color-popover | bg-popover | Dropdown / tooltip background |
| --color-popover-fg | text-popover-foreground | Text inside popovers |
| --color-muted | bg-muted | Subtle background for sidebars, code blocks |
| --color-muted-fg | text-muted-foreground | De-emphasized text (labels, placeholders) |
| --color-accent | bg-accent | Hover background for interactive items |
| --color-accent-fg | text-accent-foreground | Text on accent backgrounds |
Brand tokens
Primary action color and its states. The main visual identity of the theme.
| CSS variable | Tailwind class | Usage |
|---|---|---|
| --color-primary | bg-primary | Main brand / action color |
| --color-primary-fg | text-primary-foreground | Text on primary backgrounds |
| --color-primary-hover | hover:bg-primary-hover | Hover state of primary |
| --color-primary-active | active:bg-primary-active | Pressed state of primary |
| --color-secondary | bg-secondary | Secondary brand color |
| --color-secondary-fg | text-secondary-foreground | Text on secondary backgrounds |
| --color-ring | ring-ring | Focus ring color |
Semantic tokens
Status and feedback colors. Consistent across all themes.
| CSS variable | Tailwind class | Usage |
|---|---|---|
| --color-destructive | bg-destructive / text-destructive | Error and danger actions |
| --color-destructive-fg | text-destructive-foreground | Text on destructive backgrounds |
| --color-success | bg-success / text-success | Positive feedback |
| --color-success-fg | text-success-foreground | Text on success backgrounds |
| --color-warning | bg-warning / text-warning | Caution and warnings |
| --color-warning-fg | text-warning-foreground | Text on warning backgrounds |
| --color-info | bg-info / text-info | Informational messages |
| --color-info-fg | text-info-foreground | Text on info backgrounds |
Border & input tokens
Consistent borders, dividers, and form inputs.
| CSS variable | Tailwind class | Usage |
|---|---|---|
| --color-border | border-border | Default border color |
| --color-border-strong | border-border-strong | Emphasized borders (dividers, separators) |
| --color-input | border-input | Form input border |
| --color-overlay | bg-overlay | Modal / dialog backdrop |
Using tokens in Tailwind
All theme tokens map directly to Tailwind utility classes. Use opacity modifiers freely — they work because the tokens are HSL channel values (no hsl() wrapper).
// Surfaces
<div className="bg-bg text-fg" />
<div className="bg-card text-card-foreground rounded-xl border border-border" />
<div className="bg-muted text-muted-foreground" />
// Primary brand
<button className="bg-primary text-primary-foreground hover:bg-primary/90">
Save changes
</button>
// With opacity modifier
<div className="bg-primary/10 text-primary border border-primary/20">
Tinted info box
</div>
// Semantic
<span className="text-success">✓ Published</span>
<span className="bg-destructive/10 text-destructive">Error</span>
<span className="bg-warning/10 text-warning">Warning</span>
// Border & focus ring
<input className="border-input focus-visible:ring-ring focus-visible:ring-2" />
// Dark mode — no extra classes needed!
// bg-muted is light on light mode, dark on dark mode automatically.Using tokens in CSS and JavaScript
/* In any .css file */
.my-component {
background-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-fg));
border: 1px solid hsl(var(--color-border));
}
/* With alpha */
.overlay {
background-color: hsl(var(--color-overlay) / 0.5);
}
/* Targeting a specific theme */
[data-theme='rose'] .my-component {
/* Override only for rose theme */
border-radius: 2rem;
}// In JavaScript / TypeScript
// Read a CSS variable at runtime
const primary = getComputedStyle(document.documentElement)
.getPropertyValue('--color-primary'); // "222 47% 11%"
// Set a CSS variable directly (bypasses ThemeProvider — advanced usage)
document.documentElement.style.setProperty('--color-primary', '270 60% 50%');Custom themes
Create a ThemeConfig object with light and dark token maps, then pass it to ThemeProvider via the themes prop. All values are HSL channel strings: "H S% L%".
Interactive theme builder
Pick a primary color and see the generated ThemeConfig code
--color-primary: 262 83% 58%import type { ThemeConfig } from '@structyl/themes';
const myTheme: ThemeConfig = {
light: {
bg: '0 0% 100%',
fg: '222 47% 11%',
card: '0 0% 100%',
primary: '262 83% 58%',
'primary-fg': '222 47% 11%',
'primary-hover': '262 83% 68%',
muted: '210 40% 96%',
'muted-fg': '215 16% 47%',
accent: '210 40% 96%',
border: '214 32% 91%',
ring: '262 83% 58%',
destructive: '0 84% 60%',
success: '142 71% 45%',
warning: '38 92% 50%',
// ... other tokens inherit from slate
},
dark: {
// dark mode overrides
bg: '222 47% 6%',
fg: '210 40% 98%',
primary: '262 83% 58%',
'primary-fg': '222 47% 11%',
// ...
},
};Extending a built-in theme
Only override the tokens you need — spread from a built-in theme to inherit everything else. This is the easiest way to create a branded variant.
import { defaultThemes } from '@structyl/themes';
import type { ThemeConfig } from '@structyl/themes';
// Brand theme — only the primary changes, everything else from slate
const brandTheme: ThemeConfig = {
light: {
...defaultThemes.slate.light,
primary: '270 60% 50%', // purple
'primary-fg': '0 0% 100%',
'primary-hover': '270 60% 57%',
'primary-active': '270 60% 44%',
ring: '270 60% 50%',
},
dark: {
...defaultThemes.slate.dark,
primary: '270 60% 70%',
'primary-fg': '270 30% 10%',
'primary-hover': '270 60% 77%',
ring: '270 60% 70%',
},
};Using custom themes at runtime
// app/layout.tsx
import { ThemeProvider } from '@structyl/themes';
import { defaultThemes } from '@structyl/themes';
import { brandTheme } from '@/lib/brand-theme';
export default function RootLayout({ children }) {
return (
<ThemeProvider
defaultTheme="brand"
// Merged with defaultThemes internally — built-ins still available
themes={{ ...defaultThemes, brand: brandTheme }}
>
{children}
</ThemeProvider>
);
}
// Any component
function ThemePicker() {
const { theme, setTheme, themes } = useTheme();
// themes = ['slate', 'zinc', 'rose', 'structyl', 'brand']
return themes.map(t => (
<button key={t} onClick={() => setTheme(t)}>{t}</button>
));
}Per-component overrides
You can scope a theme to a subtree by writing the CSS variables directly on a wrapper element. This is useful for marketing sections with a different brand color from the rest of the app.
// Scoped inline override — no ThemeProvider needed
function HeroBanner() {
return (
<section
style={{
'--color-primary': '270 60% 50%',
'--color-primary-fg': '0 0% 100%',
'--color-bg': '270 30% 6%',
'--color-fg': '270 20% 98%',
} as React.CSSProperties}
className="bg-bg text-fg"
>
<h1 className="text-primary">Purple hero section</h1>
<button className="bg-primary text-primary-foreground">Get started</button>
</section>
);
}SSR & flash prevention
Without special handling, server-rendered HTML always uses the default theme. When the client hydrates and reads localStorage, it switches — causing a visible flash.ThemeScript prevents this by injecting an inline script that reads localStorage and sets the correct data-theme attribute before the browser paints.
// app/layout.tsx — Next.js App Router
import { ThemeProvider, ThemeScript } from '@structyl/themes';
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
{/*
ThemeScript must be the FIRST child of <head>.
It runs synchronously, so it blocks paint — but only for ~1ms.
The script reads localStorage and writes data-theme/data-mode
to <html> before React hydrates.
*/}
<ThemeScript storageKey="my-app-theme" defaultTheme="slate" defaultMode="system" />
</head>
<body>
<ThemeProvider storageKey="my-app-theme" defaultTheme="slate" defaultMode="system">
{children}
</ThemeProvider>
</body>
</html>
);
}storageKey in ThemeScript and ThemeProvider must match. They both default to 'structyl-theme'.Persistence & storage
By default, the chosen theme and mode are persisted to localStorage under the key structyl-theme as{ theme, mode }.
// Disable persistence (e.g. for theme preview / demo components)
<ThemeProvider storageKey={false}>
{children}
</ThemeProvider>
// Custom storage key to avoid collisions between apps on the same domain
<ThemeProvider storageKey="my-app-v2-theme">
{children}
</ThemeProvider>
// What's stored in localStorage:
// Key: "structyl-theme"
// Value: '{"theme":"rose","mode":"dark"}'API Reference
ThemeProvider
| Prop | Type | Default | Description |
|---|---|---|---|
| defaultTheme | string | 'slate' | Name of the theme to use on first load. Must be a key in the themes map. |
| defaultMode | 'light' | 'dark' | 'system' | 'system' | Color mode on first load. 'system' follows prefers-color-scheme. |
| storageKey | string | false | 'structyl-theme' | localStorage key for persistence. Pass false to disable saving to storage. |
| enableTransitions | boolean | true | When true, CSS transitions are active during theme switches. Pass false for instant switching (e.g. performance-sensitive UIs). |
| themes | Record<string, ThemeConfig> | defaultThemes | Custom theme map merged with built-in themes. Use to add or override themes. |
| attribute | string | 'data-theme' | HTML attribute written to <html> with the active theme name. Change if you need data-color-scheme or similar. |
| children | React.ReactNode | — | Your app. |
useTheme()
Must be called inside a ThemeProvider.
| Field | Type | Description |
|---|---|---|
| theme | string | Active theme name (e.g. "slate", "rose"). |
| setTheme | (name: string) => void | Switch the active theme. Persists to localStorage. |
| mode | 'light' | 'dark' | 'system' | The mode setting. 'system' means it follows the OS. |
| setMode | (mode: ThemeMode) => void | Switch the color mode. Persists to localStorage. |
| resolvedMode | 'light' | 'dark' | The actual rendered mode. Never 'system' — always resolved. |
| themes | string[] | All available theme names (built-in + custom). |
ThemeScript
| Prop | Type | Default | Description |
|---|---|---|---|
| storageKey | string | 'structyl-theme' | Must match the storageKey passed to ThemeProvider. |
| defaultTheme | string | 'slate' | Fallback theme if no value exists in localStorage. |
| defaultMode | 'light' | 'dark' | 'system' | 'system' | Fallback mode if no value exists in localStorage. |
ThemeConfig interface
interface ThemeConfig {
light: Partial<ThemeTokens> & Record<string, string>;
dark: Partial<ThemeTokens> & Record<string, string>;
}
// All token names — you can override any subset
interface ThemeTokens {
bg: string; fg: string;
card: string; 'card-fg': string;
popover: string; 'popover-fg': string;
primary: string; 'primary-fg': string;
'primary-hover': string; 'primary-active': string;
secondary: string; 'secondary-fg': string;
muted: string; 'muted-fg': string;
accent: string; 'accent-fg': string;
destructive: string; 'destructive-fg': string;
success: string; 'success-fg': string;
warning: string; 'warning-fg': string;
info: string; 'info-fg': string;
border: string; 'border-strong': string;
input: string; ring: string;
overlay: string; shadow: string;
// + 50 additional semantic sub-tokens (see types.ts)
[key: string]: string; // arbitrary custom tokens
}