Theming
The Problem
Most apps need a way to define colors, spacing, and typography centrally and apply them across components. React uses CSS-in-JS libraries or CSS custom properties. Vue has scoped styles. Svelte has CSS variables and reactive stores.
CellUI has two approaches: ambient() for simple global themes, and createSubstrate() for scoped theming with overrides.
Simple Theming with ambient()
For most apps, a global theme signal is enough:
typescriptimport { ambient, view, effect } from '@cmj/cellui';
const Theme = ambient('theme', {
bg: '#09090b',
surface: '#111',
text: '#fafafa',
muted: '#a1a1aa',
accent: '#14b8a6',
radius: '8px',
});
function Card(title: string, content: string) {
return view`
<div style="background: ${Theme.surface}; border-radius: ${Theme.radius}; padding: 20px;">
<h3 style="color: ${Theme.text}">${title}</h3>
<p style="color: ${Theme.muted}">${content}</p>
</div>
`;
}When any theme value changes, the bound style attributes update. Change Theme.accent.value = '#ef4444' and every component using it updates instantly.
Dark/light mode toggle
typescriptconst darkTheme = {
bg: '#09090b', surface: '#111', text: '#fafafa',
muted: '#a1a1aa', accent: '#14b8a6', radius: '8px',
};
const lightTheme = {
bg: '#ffffff', surface: '#f5f5f5', text: '#111',
muted: '#666', accent: '#0d9488', radius: '8px',
};
const Theme = ambient('theme', darkTheme);
function ThemeToggle() {
const isDark = ambient('isDark', true);
const toggle = () => {
isDark.value = !isDark.value;
Theme.value = isDark.value ? darkTheme : lightTheme;
};
return view`
<button onclick="${toggle}" style="background: ${Theme.accent}; color: white; border: none; padding: 8px 16px; border-radius: ${Theme.radius}; cursor: pointer;">
${() => isDark.value ? 'Light Mode' : 'Dark Mode'}
</button>
`;
}Applying theme to CSS custom properties
Instead of inline styles, sync theme signals to CSS custom properties:
typescripteffect(() => {
const root = document.documentElement;
root.style.setProperty('--bg', Theme.bg.value);
root.style.setProperty('--surface', Theme.surface.value);
root.style.setProperty('--text', Theme.text.value);
root.style.setProperty('--muted', Theme.muted.value);
root.style.setProperty('--accent', Theme.accent.value);
root.style.setProperty('--radius', Theme.radius.value);
});Now your regular CSS can use var(--bg), var(--accent), etc. — and they update reactively when the theme changes.
Scoped Theming with Substrates
For apps that need different themes in different sections (e.g., a marketing site with a dark header and light content area), use createSubstrate():
typescriptimport { createSubstrate, signal } from '@cmj/cellui';
function ThemedSection(zoneName: string, overrides: Partial<typeof defaultTheme>) {
const theme = createSubstrate(zoneName);
// Connect with defaults, overridden by the caller
const merged = { ...defaultTheme, ...overrides };
const bg = theme.connect('bg', signal(merged.bg));
const text = theme.connect('text', signal(merged.text));
const accent = theme.connect('accent', signal(merged.accent));
return { bg, text, accent, theme };
}
// Dark header
const header = ThemedSection('header', { bg: '#09090b', text: '#fafafa' });
// Light content
const content = ThemedSection('content', { bg: '#ffffff', text: '#111' });
function Header() {
return view`
<header style="background: ${header.bg}; color: ${header.text}; padding: 20px;">
<h1>My App</h1>
</header>
`;
}
function Content() {
return view`
<main style="background: ${content.bg}; color: ${content.text}; padding: 40px;">
<p>Content area with different theme.</p>
</main>
`;
}Each createSubstrate() creates an isolated scope. The 'header' zone's signals are independent from the 'content' zone's signals. But both can delegate to global values using the global: prefix:
typescript// Both zones share the same accent color from ambient()
const headerAccent = header.theme.connect('global:accent', signal('#14b8a6'));
const contentAccent = content.theme.connect('global:accent');
// headerAccent === contentAccent — same signalCleaning up scoped themes
When a themed section unmounts, destroy its substrate:
typescriptCellUI.onDispose(sectionEl, () => {
header.theme.destroy(); // clears all connected signals in this zone
});Token System Pattern
For design-system-level theming, define typed tokens:
typescriptinterface ThemeTokens {
colors: {
bg: string;
surface: string;
text: string;
muted: string;
accent: string;
error: string;
success: string;
};
spacing: {
xs: string; sm: string; md: string; lg: string; xl: string;
};
radius: {
sm: string; md: string; lg: string; full: string;
};
font: {
sans: string;
mono: string;
};
}
const tokens: ThemeTokens = {
colors: {
bg: '#09090b', surface: '#111', text: '#fafafa',
muted: '#a1a1aa', accent: '#14b8a6', error: '#ef4444', success: '#22c55e',
},
spacing: { xs: '4px', sm: '8px', md: '16px', lg: '24px', xl: '40px' },
radius: { sm: '4px', md: '8px', lg: '12px', full: '9999px' },
font: { sans: 'Inter, system-ui, sans-serif', mono: 'JetBrains Mono, monospace' },
};
const Theme = ambient('design-tokens', tokens);
// Usage in components:
view`
<div style="
background: ${Theme.colors.surface};
padding: ${Theme.spacing.md};
border-radius: ${Theme.radius.md};
font-family: ${Theme.font.sans};
">
<p style="color: ${Theme.colors.text}">${content}</p>
</div>
`;When NOT to Use Signal-Based Theming
- Static themes — if your theme never changes at runtime, use CSS custom properties directly. No signals needed.
- Tailwind/utility CSS — if you use Tailwind, theme via
tailwind.config.js, not signals. Adding signals on top of Tailwind's class system creates two sources of truth. - Component libraries — if you're consuming a third-party component library, follow its theming API instead of overriding with CellUI signals.
Signal-based theming is for apps that need runtime theme switching (dark mode, user preferences, white-label branding). If your theme is static, CSS is simpler and faster.