Soft UI’s theming system is built on CSS variables and data attributes, allowing live theme switching without page reload.
Theme Attributes
Themes are controlled by four HTML data attributes:
<html
data-mode="light"
data-scheme="mono"
data-theme-color="blue"
data-base-color="neutral"
>
data-mode
Controls light/dark mode:
light: Light mode
dark: Dark mode
document.documentElement.dataset.mode = 'dark';
Mode affects semantic tokens like --color-surface-page and --color-content-strong, adapting them for optimal contrast.
data-scheme
Controls whether primary actions use neutral or theme colors:
mono: Primary actions use neutral colors (grayscale)
color: Primary actions use theme colors
document.documentElement.dataset.scheme = 'color';
Mono scheme:
/* Primary button uses base colors */
--actions-primary-default: var(--base-800);
--actions-primary-hover: var(--base-900);
Color scheme:
/* Primary button uses theme colors */
--actions-primary-default: var(--theme-600);
--actions-primary-hover: var(--theme-700);
Use mono for professional/enterprise interfaces, color for consumer/creative products.
data-theme-color
Sets the accent color palette:
Available colors: red, orange, amber, yellow, lime, green, emerald, teal, cyan, sky, blue (default), indigo, violet, purple, fuchsia, pink, rose, mauve, mist, olive, taupe
document.documentElement.dataset.themeColor = 'violet';
This remaps all --theme-* variables to the selected palette:
html[data-theme-color="violet"] {
--theme-50: var(--violet-50);
--theme-100: var(--violet-100);
--theme-200: var(--violet-200);
/* ... through --theme-950 */
}
data-base-color
Sets the neutral/grayscale palette:
Available colors: neutral (default), slate, gray, zinc, stone, mauve, mist, olive, taupe
document.documentElement.dataset.baseColor = 'slate';
This remaps all --base-* variables:
html[data-base-color="slate"] {
--base-50: var(--slate-50);
--base-100: var(--slate-100);
/* ... through --base-950 */
}
Neutral grays (mauve, mist, olive, taupe) are custom palettes designed for Soft UI with subtle tints.
Theme Initialization
Use createThemeInitScript from @soft-ui/tokens to prevent flash of unstyled content:
import { createThemeInitScript } from '@soft-ui/tokens';
export default function RootLayout({ children }) {
return (
<html suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: createThemeInitScript({
defaults: {
defaultMode: 'light',
defaultScheme: 'mono',
defaultThemeColor: 'blue',
defaultBaseColor: 'neutral',
},
storageKeys: {
mode: 'ds-mode',
scheme: 'ds-scheme',
theme: 'ds-theme-color',
base: 'ds-base-color',
},
}),
}}
/>
</head>
<body>{children}</body>
</html>
);
}
The init script runs before React hydration, reading values from localStorage and applying them immediately.
Persisting Theme State
Theme preferences are stored in localStorage with configurable keys:
const storageKeys = {
mode: 'ds-mode', // light | dark
scheme: 'ds-scheme', // mono | color
theme: 'ds-theme-color', // blue | violet | etc.
base: 'ds-base-color', // neutral | slate | etc.
};
The init script:
- Reads stored values from
localStorage
- Falls back to defaults if not found
- Respects system preference for mode (if no override)
- Applies values to
data-* attributes
System Preference Detection
The init script detects system dark mode preference:
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
If no mode override is stored, it follows system preference and updates automatically when system preference changes.
Theme Switching
Manual Switching
function toggleMode() {
const html = document.documentElement;
const newMode = html.dataset.mode === 'light' ? 'dark' : 'light';
html.dataset.mode = newMode;
localStorage.setItem('ds-mode', newMode);
}
function toggleScheme() {
const html = document.documentElement;
const newScheme = html.dataset.scheme === 'mono' ? 'color' : 'mono';
html.dataset.scheme = newScheme;
localStorage.setItem('ds-scheme', newScheme);
}
function setThemeColor(color: string) {
document.documentElement.dataset.themeColor = color;
localStorage.setItem('ds-theme-color', color);
}
function setBaseColor(color: string) {
document.documentElement.dataset.baseColor = color;
localStorage.setItem('ds-base-color', color);
}
React Hook Example
import { useEffect, useState } from 'react';
type Mode = 'light' | 'dark';
type Scheme = 'mono' | 'color';
export function useTheme() {
const [mode, setModeState] = useState<Mode>('light');
const [scheme, setSchemeState] = useState<Scheme>('mono');
useEffect(() => {
const html = document.documentElement;
setModeState(html.dataset.mode as Mode);
setSchemeState(html.dataset.scheme as Scheme);
}, []);
const setMode = (newMode: Mode) => {
document.documentElement.dataset.mode = newMode;
localStorage.setItem('ds-mode', newMode);
setModeState(newMode);
};
const setScheme = (newScheme: Scheme) => {
document.documentElement.dataset.scheme = newScheme;
localStorage.setItem('ds-scheme', newScheme);
setSchemeState(newScheme);
};
return { mode, setMode, scheme, setScheme };
}
Theme Combinations
Common Configurations
Professional/Enterprise:
data-mode="light"
data-scheme="mono"
data-theme-color="blue"
data-base-color="neutral"
Creative/Consumer:
data-mode="light"
data-scheme="color"
data-theme-color="violet"
data-base-color="mauve"
Developer Tools:
data-mode="dark"
data-scheme="color"
data-theme-color="cyan"
data-base-color="slate"
Warm & Friendly:
data-mode="light"
data-scheme="color"
data-theme-color="amber"
data-base-color="taupe"
How Tokens Adapt
Surface Colors
Light mode:
--surface-page: var(--pure-white);
--surface-canvas: var(--base-50);
--surface-overlay: var(--pure-white);
Dark mode:
--surface-page: var(--base-900);
--surface-canvas: var(--darken-16);
--surface-overlay: var(--base-800);
Content Colors
Light mode:
--content-strong: var(--base-800);
--content-subtle: var(--base-500);
--content-muted: var(--base-400);
Dark mode:
--content-strong: var(--base-200);
--content-subtle: var(--base-400);
--content-muted: var(--base-500);
Action Colors (Scheme-Dependent)
Mono scheme (light):
--actions-primary-default: var(--base-800);
--actions-primary-hover: var(--base-900);
Color scheme (light):
--actions-primary-default: var(--theme-600);
--actions-primary-hover: var(--theme-700);
Mono scheme (dark):
--actions-primary-default: var(--base-200);
--actions-primary-hover: var(--base-100);
Color scheme (dark):
--actions-primary-default: var(--theme-500);
--actions-primary-hover: var(--theme-400);
Danger Color Handling
When theme color is set to rose, danger actions switch to red to maintain distinction:
html[data-theme-color="rose"] {
--danger-50: var(--red-50);
--danger-100: var(--red-100);
/* ... through --danger-950 */
}
Otherwise, danger uses rose:
--danger-50: var(--rose-50);
--danger-100: var(--rose-100);
/* ... */
Best Practices
Do:
- Use semantic tokens (
--color-surface-page) instead of raw palette tokens (--blue-500)
- Test both light and dark modes
- Test both mono and color schemes
- Provide theme controls in settings/preferences
Don’t:
- Hardcode colors (use tokens)
- Assume a specific theme color is set
- Forget to persist theme state to localStorage
- Skip the init script (causes flash of unstyled content)
Extending the Theme
To add a custom theme color:
- Define the palette in
tokens.css:
--custom-50: 250 245 255;
--custom-100: 243 232 255;
/* ... through --custom-950 */
- Add a theme mapping:
html[data-theme-color="custom"] {
--theme-50: var(--custom-50);
--theme-100: var(--custom-100);
/* ... through --theme-950 */
}
- Update TypeScript types in
theme.ts:
export const themeColors = [
// existing colors...
"custom",
] as const;