structyl

Themes

stable

Runtime 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

bash
pnpm add @structyl/themes

2. Wrap your app

Place ThemeProvider at the root of your app — above any component that reads theme tokens or calls useTheme.

tsx
// 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>
  );
}
NOTE:Add 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))].

ts
// 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.

tsx
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

A

Alice Chen

Engineer

active
B

Bob Smith

Designer

away
C

Carol Wu

Product

active

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'.

tsx
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-bg

fg

--color-fg

text-fg

card

--color-card

bg-card

muted

--color-muted

bg-muted

accent

--color-accent

bg-accent

Brand

primary

--color-primary

bg-primary

primary-fg

--color-primary-fg

text-primary-foreground

muted-fg

--color-muted-fg

text-muted-foreground

border

--color-border

border-border

Semantic

success

--color-success

text-success

warning

--color-warning

text-warning

destructive

--color-destructive

text-destructive

Token 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 variableTailwind classUsage
--color-bgbg-bgPage background
--color-fgtext-fgDefault text color
--color-cardbg-cardCard / panel surface
--color-card-fgtext-card-foregroundText on card surfaces
--color-popoverbg-popoverDropdown / tooltip background
--color-popover-fgtext-popover-foregroundText inside popovers
--color-mutedbg-mutedSubtle background for sidebars, code blocks
--color-muted-fgtext-muted-foregroundDe-emphasized text (labels, placeholders)
--color-accentbg-accentHover background for interactive items
--color-accent-fgtext-accent-foregroundText on accent backgrounds

Brand tokens

Primary action color and its states. The main visual identity of the theme.

CSS variableTailwind classUsage
--color-primarybg-primaryMain brand / action color
--color-primary-fgtext-primary-foregroundText on primary backgrounds
--color-primary-hoverhover:bg-primary-hoverHover state of primary
--color-primary-activeactive:bg-primary-activePressed state of primary
--color-secondarybg-secondarySecondary brand color
--color-secondary-fgtext-secondary-foregroundText on secondary backgrounds
--color-ringring-ringFocus ring color

Semantic tokens

Status and feedback colors. Consistent across all themes.

CSS variableTailwind classUsage
--color-destructivebg-destructive / text-destructiveError and danger actions
--color-destructive-fgtext-destructive-foregroundText on destructive backgrounds
--color-successbg-success / text-successPositive feedback
--color-success-fgtext-success-foregroundText on success backgrounds
--color-warningbg-warning / text-warningCaution and warnings
--color-warning-fgtext-warning-foregroundText on warning backgrounds
--color-infobg-info / text-infoInformational messages
--color-info-fgtext-info-foregroundText on info backgrounds

Border & input tokens

Consistent borders, dividers, and form inputs.

CSS variableTailwind classUsage
--color-borderborder-borderDefault border color
--color-border-strongborder-border-strongEmphasized borders (dividers, separators)
--color-inputborder-inputForm input border
--color-overlaybg-overlayModal / 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).

tsx
// 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

css
/* 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;
}
ts
// 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

M
My Brand
ActivePendingError
Token: --color-primary: 262 83% 58%
ts
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.

ts
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

tsx
// 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.

tsx
// 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.

tsx
// 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>
  );
}
TIP:The 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 }.

ts
// 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

PropTypeDefaultDescription
defaultThemestring'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.
storageKeystring | false'structyl-theme'localStorage key for persistence. Pass false to disable saving to storage.
enableTransitionsbooleantrueWhen true, CSS transitions are active during theme switches. Pass false for instant switching (e.g. performance-sensitive UIs).
themesRecord<string, ThemeConfig>defaultThemesCustom theme map merged with built-in themes. Use to add or override themes.
attributestring'data-theme'HTML attribute written to <html> with the active theme name. Change if you need data-color-scheme or similar.
childrenReact.ReactNodeYour app.

useTheme()

Must be called inside a ThemeProvider.

FieldTypeDescription
themestringActive theme name (e.g. "slate", "rose").
setTheme(name: string) => voidSwitch the active theme. Persists to localStorage.
mode'light' | 'dark' | 'system'The mode setting. 'system' means it follows the OS.
setMode(mode: ThemeMode) => voidSwitch the color mode. Persists to localStorage.
resolvedMode'light' | 'dark'The actual rendered mode. Never 'system' — always resolved.
themesstring[]All available theme names (built-in + custom).

ThemeScript

PropTypeDefaultDescription
storageKeystring'structyl-theme'Must match the storageKey passed to ThemeProvider.
defaultThemestring'slate'Fallback theme if no value exists in localStorage.
defaultMode'light' | 'dark' | 'system''system'Fallback mode if no value exists in localStorage.

ThemeConfig interface

ts
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
}