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 signal

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