Web Loom logo
Chapter 21Advanced Topics

Design Systems and Theming

Chapter 21: Design Systems and Theming

In previous chapters, we've built ViewModels that manage application state, Models that encapsulate business logic, and Views that render UI across multiple frameworks. But we haven't yet addressed a critical aspect of modern applications: consistent visual design. How do you maintain design consistency across components, frameworks, and themes? How do you enable dark mode without duplicating styles? How do you ensure your design system scales as your application grows?

This chapter explores design token systems and theming patterns in general terms, then demonstrates these concepts using the design-core library from the Web Loom monorepo as a concrete example. The goal isn't to prescribe a specific library, but to teach you the underlying principles so you can build scalable, maintainable design systems for your MVVM applications.

Why Design Systems Matter for MVVM

MVVM architecture separates concerns: Models handle business logic, ViewModels manage presentation state, and Views render UI. But this separation creates a challenge: how do you maintain visual consistency across multiple View implementations?

Consider the GreenWatch application we've built throughout this book. We have the same ViewModels running in React, Vue, Angular, Lit, and vanilla JavaScript. Each framework has its own styling approach:

  • React: CSS Modules, styled-components, Tailwind
  • Vue: Scoped styles, CSS Modules
  • Angular: Component styles with ViewEncapsulation
  • Lit: Shadow DOM with adoptedStyleSheets
  • Vanilla JS: Global CSS or inline styles

Without a design system, you'd need to duplicate color values, spacing units, and typography settings across all these implementations. When you want to add dark mode or change your brand colors, you'd need to update styles in five different places. This violates the DRY principle and makes maintenance painful.

A design system solves this by providing a single source of truth for visual design decisions. Just as ViewModels provide a single source of truth for presentation logic, design tokens provide a single source of truth for visual properties.

Core Concepts: Design Tokens

Design tokens are named entities that store visual design attributes. Instead of hardcoding #1E40AF throughout your application, you define a token like color.base.primary with that value. Tokens represent:

  • Colors: Brand colors, semantic colors, neutrals
  • Spacing: Margins, padding, gaps
  • Typography: Font families, sizes, weights, line heights
  • Shadows: Elevation and depth effects
  • Borders: Widths, styles, radii
  • Timing: Animation durations and easing functions

The key insight is that tokens are abstract and platform-agnostic. They're defined once in a structured format (typically JSON or YAML), then transformed into platform-specific formats:

Design Tokens (JSON)
    ↓
    ├─→ CSS Custom Properties (--color-primary: #1E40AF)
    ├─→ JavaScript Objects ({ colorPrimary: '#1E40AF' })
    ├─→ iOS Swift (UIColor.primary)
    └─→ Android XML (<color name="primary">#1E40AF</color>)

For web applications, CSS Custom Properties (CSS Variables) are the primary output format because they work across all frameworks and support runtime theming.

Token Structure and Organization

Tokens are typically organized hierarchically:

colors
  ├─ base
  │   ├─ primary: #1E40AF
  │   ├─ secondary: #64748B
  │   └─ success: #10B981
  ├─ neutral
  │   ├─ white: #FFFFFF
  │   ├─ black: #000000
  │   └─ gray
  │       ├─ 100: #F3F4F6
  │       ├─ 200: #E5E7EB
  │       └─ ...
  └─ themed
      ├─ light
      │   ├─ background: {colors.neutral.white}
      │   └─ text: {colors.neutral.black}
      └─ dark
          ├─ background: {colors.neutral.900}
          └─ text: {colors.neutral.white}

Notice the themed section uses token references (e.g., {colors.neutral.white}). This is a powerful pattern: tokens can reference other tokens, creating a semantic layer on top of primitive values. When you change colors.neutral.white, all tokens that reference it automatically update.

Token Formats: DTCG Standard

The Design Tokens Community Group (DTCG) has established a standard format for design tokens:

{
  "color": {
    "base": {
      "primary": {
        "value": "#1E40AF",
        "type": "color",
        "description": "Primary brand color"
      }
    }
  }
}

Each token has:

  • value: The actual value
  • type: The token type (color, spacing, dimension, etc.)
  • description: Human-readable documentation

This standardization enables interoperability between design tools (Figma, Sketch) and code.

Design Token System Implementation

Let's see how to implement a design token system. We'll use the design-core library from the Web Loom monorepo as a concrete example, but the patterns apply to any implementation.

Token Definition

Tokens are defined in JSON files, one per category. Here's an excerpt from packages/design-core/src/tokens/colors.json:

{
  "color": {
    "base": {
      "primary": {
        "value": "#1E40AF",
        "type": "color",
        "description": "Primary brand color"
      },
      "secondary": {
        "value": "#64748B",
        "type": "color",
        "description": "Secondary brand color"
      }
    },
    "neutral": {
      "white": {
        "value": "#FFFFFF",
        "type": "color"
      },
      "gray": {
        "900": {
          "value": "#111827",
          "type": "color"
        }
      }
    },
    "themed": {
      "light": {
        "background": {
          "value": "{color.neutral.white.value}",
          "type": "color"
        }
      },
      "dark": {
        "background": {
          "value": "{color.neutral.gray.900.value}",
          "type": "color"
        }
      }
    }
  }
}

And from packages/design-core/src/tokens/spacing.json:

{
  "spacing": {
    "1": {
      "value": "4px",
      "type": "spacing",
      "description": "Spacing unit 1"
    },
    "4": {
      "value": "16px",
      "type": "spacing",
      "description": "Spacing unit 4"
    },
    "gutter": {
      "value": "{spacing.4.value}",
      "type": "spacing",
      "description": "Default gutter size"
    },
    "padding": {
      "md": {
        "value": "{spacing.4.value}",
        "type": "spacing"
      }
    }
  }
}

Notice how spacing.gutter and spacing.padding.md reference spacing.4. This creates a semantic layer: if you change the base spacing unit, all derived values update automatically.

Token Loading and Processing

The token system needs to:

  1. Load token files dynamically
  2. Extract values from the DTCG format
  3. Resolve token references
  4. Cache processed tokens

Here's how design-core implements this in packages/design-core/src/utils/tokens.ts:

// Token files to load
const tokenFiles = [
  'colors.json',
  'spacing.json',
  'typography.json',
  'shadows.json',
  // ... more categories
];
 
// Cache for processed tokens
const masterTokens: DesignTokens = {};
let masterTokensInitialized = false;
 
// Extract 'value' from token objects
function processTokenNode(node: any): TokenValue | TokenGroup {
  if (typeof node === 'object' && node !== null) {
    if ('value' in node) {
      return node.value;  // Extract the value
    }
    // Recurse for nested groups
    const processedNode: TokenGroup = {};
    for (const key in node) {
      processedNode[key] = processTokenNode(node[key]);
    }
    return processedNode;
  }
  return node;
}

The reference resolution is critical. Tokens like {color.neutral.white.value} need to be resolved to their actual values:

// Resolve token references like "{colors.base.primary.value}"
function resolveTokenReferences(tokens: DesignTokens): void {
  const referenceRegex = /^{([^}]+)\.value}$/;
  
  const getReferencedValue = (path: string, allTokens: DesignTokens) => {
    const parts = path.split('.');
    let current: any = allTokens;
    for (const part of parts) {
      if (current && typeof current === 'object' && part in current) {
        current = current[part];
      } else {
        return undefined;  // Path not found
      }
    }
    return current;
  };
  
  // Walk through all tokens and resolve references
  walkTokens(tokens, (value, pathArray) => {
    if (typeof value === 'string') {
      const match = value.match(referenceRegex);
      if (match) {
        const referencePath = match[1];
        const resolvedValue = getReferencedValue(referencePath, tokens);
        if (resolvedValue !== undefined) {
          // Update the token with the resolved value
          updateTokenAtPath(tokens, pathArray, resolvedValue);
        }
      }
    }
  });
}

This two-pass approach (extract values, then resolve references) ensures all tokens are properly processed before use.

Accessing Tokens in JavaScript

Once tokens are loaded and processed, you can access them programmatically:

import { getTokenValue, getAllTokens } from '@web-loom/design-core/utils';
 
// Get a specific token value
const primaryColor = await getTokenValue('color.base.primary');
console.log(primaryColor);  // "#1E40AF"
 
// Get all tokens
const allTokens = await getAllTokens();
console.log(allTokens.color.base.primary);  // "#1E40AF"

The async API is necessary because tokens are loaded dynamically from JSON files. This keeps the initial bundle size small and allows for lazy loading of token categories.

CSS Custom Properties Generation

The most powerful way to use design tokens in web applications is through CSS Custom Properties (CSS Variables). They provide:

  • Runtime theming: Change values without recompiling CSS
  • Framework independence: Work in React, Vue, Angular, vanilla JS
  • Cascade and inheritance: Leverage CSS's natural scoping
  • Browser DevTools support: Inspect and modify values in real-time

Generating CSS Variables

The token system needs to transform the hierarchical token structure into flat CSS variable declarations. Here's how design-core implements this in packages/design-core/src/utils/cssVariables.ts:

// Convert token path to CSS variable name
// "color.base.primary" → "--color-base-primary"
export function pathToCssVar(path: string): string {
  return `--${path.replace(/\./g, '-')}`;
}
 
// Flatten nested tokens into CSS variables
function flattenTokensToCssVarsRecursive(
  tokens: DesignTokens,
  currentPath: string = '',
  cssVarsMap: Record<string, TokenValue> = {}
): Record<string, TokenValue> {
  for (const key in tokens) {
    const value = tokens[key];
    const newPath = currentPath ? `${currentPath}.${key}` : key;
    
    if (typeof value === 'object' && value !== null) {
      // Nested group, recurse
      flattenTokensToCssVarsRecursive(value, newPath, cssVarsMap);
    } else if (value !== undefined && value !== null) {
      // Leaf node, add to map
      cssVarsMap[pathToCssVar(newPath)] = value;
    }
  }
  return cssVarsMap;
}
 
// Generate CSS variable declarations
export async function generateCssVariablesString(
  selector: string = ':root'
): Promise<string> {
  const cssVarsMap = await generateCssVariablesMap();
  
  let cssString = `${selector} {\n`;
  for (const varName in cssVarsMap) {
    cssString += `  ${varName}: ${cssVarsMap[varName]};\n`;
  }
  cssString += `}\n`;
  
  return cssString;
}

This generates CSS like:

:root {
  --color-base-primary: #1E40AF;
  --color-base-secondary: #64748B;
  --color-neutral-white: #FFFFFF;
  --color-neutral-gray-900: #111827;
  --spacing-1: 4px;
  --spacing-4: 16px;
  --spacing-gutter: 16px;
  /* ... hundreds more variables */
}

Injecting CSS Variables

You can inject these variables into your application in several ways:

1. Dynamic injection (JavaScript):

import { generateCssVariablesString } from '@web-loom/design-core/utils';
 
async function setupDesignTokens() {
  const cssVars = await generateCssVariablesString(':root');
  
  const styleTag = document.createElement('style');
  styleTag.id = 'design-tokens';
  styleTag.textContent = cssVars;
  document.head.appendChild(styleTag);
}
 
// Call early in application lifecycle
setupDesignTokens();

2. Pre-generated CSS files:

The design-core package includes pre-generated CSS files that you can import directly:

// Import all token CSS variables
import '@web-loom/design-core/design-system';
 
// Or import specific categories
import '@web-loom/design-core/src/css/colors.css';
import '@web-loom/design-core/src/css/spacing.css';

3. Build-time generation:

Generate CSS files during your build process:

// scripts/generate-css.js
import { generateCssVariablesString } from '@web-loom/design-core/utils';
import fs from 'fs';
 
const css = await generateCssVariablesString(':root');
fs.writeFileSync('dist/tokens.css', css);

Using CSS Variables in Styles

Once injected, you can use these variables in any CSS:

.sensor-card {
  background: var(--color-neutral-white);
  border: 1px solid var(--color-neutral-gray-200);
  padding: var(--spacing-4);
  border-radius: var(--radii-md);
  box-shadow: var(--shadows-sm);
}
 
.sensor-reading {
  color: var(--color-base-primary);
  font-size: var(--typography-fontSize-md);
  font-weight: var(--typography-fontWeight-semibold);
}

The beauty of this approach is that it works identically across all frameworks. Your React components, Vue components, Angular components, and vanilla JavaScript all reference the same CSS variables, ensuring perfect visual consistency.

Helper Functions for CSS Variables

The design-core library provides helper functions for working with CSS variables in JavaScript:

import { 
  getTokenVar, 
  getSafeTokenVar 
} from '@web-loom/design-core/utils';
 
// Get CSS variable reference
const primaryColorVar = getTokenVar('color.base.primary');
// Returns: "var(--color-base-primary)"
 
// Apply to element
element.style.backgroundColor = primaryColorVar;
 
// Safe version that checks if token exists
const accentVar = await getSafeTokenVar('color.base.accent');
if (accentVar) {
  element.style.color = accentVar;
} else {
  console.warn('Token not found, using fallback');
  element.style.color = '#000000';
}

These helpers are particularly useful when applying styles dynamically in JavaScript, such as in ViewModels that need to compute styles based on state.

Dynamic Theming

The real power of CSS Custom Properties emerges with dynamic theming. Instead of duplicating styles for light and dark modes, you override specific CSS variables based on a theme selector.

Theme Structure

A theme is a set of token overrides. Here's the conceptual model:

interface Theme {
  name: string;                    // "dark", "high-contrast", etc.
  tokens: Partial<DesignTokens>;   // Token overrides
}

For example, a dark theme might override background and text colors:

const darkTheme = {
  name: 'dark',
  tokens: {
    color: {
      themed: {
        background: '#121212',
        text: '#E0E0E0'
      }
    },
    shadows: {
      medium: '0 4px 12px rgba(255, 255, 255, 0.1)'
    }
  }
};

Creating and Applying Themes

The design-core library provides a complete theming API in packages/design-core/src/utils/theme.ts:

import { createTheme, applyTheme, setTheme } from '@web-loom/design-core/utils';
 
// Create a dark theme
const darkTheme = createTheme('dark', {
  color: {
    themed: {
      light: {
        background: { value: '#121212' },
        text: { value: '#E0E0E0' }
      }
    },
    base: {
      primary: { value: '#3B82F6' }  // Slightly different blue for dark mode
    }
  },
  shadows: {
    medium: { value: '0 4px 12px rgba(255, 255, 255, 0.1)' }
  }
});
 
// Apply theme styles (generates CSS for [data-theme="dark"])
await applyTheme(darkTheme);
 
// Activate the theme (sets data-theme attribute on <html>)
setTheme('dark');

The createTheme function creates a theme object with token overrides. The applyTheme function generates CSS variable declarations scoped to a data-theme attribute:

[data-theme="dark"] {
  --color-themed-light-background: #121212;
  --color-themed-light-text: #E0E0E0;
  --color-base-primary: #3B82F6;
  --shadows-medium: 0 4px 12px rgba(255, 255, 255, 0.1);
}

When you call setTheme('dark'), it sets <html data-theme="dark">, which activates these overrides. All components automatically use the new values because they reference CSS variables.

Theme Implementation Details

Here's how applyTheme works internally:

export async function applyTheme(
  theme: Theme, 
  applyToRoot: boolean = false
): Promise<void> {
  const selector = applyToRoot ? ':root' : `[data-theme="${theme.name}"]`;
  const styleElementId = `dynamic-theme-styles-${theme.name}`;
  
  // Flatten theme overrides to CSS variables
  const cssVarsMap = flattenThemeOverridesToCssVars(theme.tokens);
  
  // Generate CSS string
  let cssString = `${selector} {\n`;
  for (const varName in cssVarsMap) {
    cssString += `  ${varName}: ${cssVarsMap[varName]};\n`;
  }
  cssString += `}\n`;
  
  // Inject or update style element
  let styleElement = document.getElementById(styleElementId);
  if (!styleElement) {
    styleElement = document.createElement('style');
    styleElement.id = styleElementId;
    document.head.appendChild(styleElement);
  }
  styleElement.textContent = cssString;
}

The applyToRoot parameter allows you to apply overrides globally (to :root) instead of scoping them to a theme selector. This is useful for base theme customization.

Theme Switching

Switching themes is as simple as changing the data-theme attribute:

export function setTheme(themeName: string): void {
  if (typeof document !== 'undefined') {
    document.documentElement.setAttribute('data-theme', themeName);
    console.log(`Theme switched to "${themeName}"`);
  }
}
 
export function getCurrentTheme(): string | null {
  if (typeof document !== 'undefined') {
    return document.documentElement.getAttribute('data-theme');
  }
  return null;
}

This makes theme switching instant—no page reload, no CSS recompilation, just a single attribute change.

Light/Dark Mode Example

Here's a complete example of implementing light/dark mode:

import { 
  createTheme, 
  applyTheme, 
  setTheme, 
  getCurrentTheme 
} from '@web-loom/design-core/utils';
 
// Define dark theme
const darkTheme = createTheme('dark', {
  color: {
    neutral: {
      white: { value: '#FFFFFF' },
      gray: {
        900: { value: '#111827' }
      }
    },
    themed: {
      dark: {
        background: { value: '#121212' },
        text: { value: '#E0E0E0' }
      }
    }
  }
});
 
// Setup themes on app initialization
async function setupThemes() {
  await applyTheme(darkTheme);
  
  // Check user preference
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  setTheme(prefersDark ? 'dark' : 'light');
  
  // Listen for system theme changes
  window.matchMedia('(prefers-color-scheme: dark)')
    .addEventListener('change', (e) => {
      setTheme(e.matches ? 'dark' : 'light');
    });
}
 
// Toggle theme manually
function toggleTheme() {
  const current = getCurrentTheme();
  setTheme(current === 'dark' ? 'light' : 'dark');
}

Framework-Agnostic Design Systems

The power of CSS Custom Properties is that they work identically across all frameworks. Let's see how the same design system integrates with different View implementations.

React Integration

import { useEffect, useState } from 'react';
import { createTheme, applyTheme, setTheme } from '@web-loom/design-core/utils';
import '@web-loom/design-core/design-system';
 
function ThemeToggle() {
  const [theme, setActiveTheme] = useState('light');
  
  useEffect(() => {
    // Setup dark theme on mount
    const darkTheme = createTheme('dark', {
      color: {
        themed: {
          dark: {
            background: { value: '#121212' },
            text: { value: '#E0E0E0' }
          }
        }
      }
    });
    applyTheme(darkTheme);
  }, []);
  
  const toggleTheme = () => {
    const newTheme = theme === 'light' ? 'dark' : 'light';
    setTheme(newTheme);
    setActiveTheme(newTheme);
  };
  
  return (
    <button 
      className="btn btn-primary" 
      onClick={toggleTheme}
    >
      Toggle Theme
    </button>
  );
}

Vue Integration

<script setup>
import { ref, onMounted } from 'vue';
import { createTheme, applyTheme, setTheme } from '@web-loom/design-core/utils';
import '@web-loom/design-core/design-system';
 
const currentTheme = ref('light');
 
onMounted(async () => {
  const darkTheme = createTheme('dark', {
    color: {
      themed: {
        dark: {
          background: { value: '#121212' },
          text: { value: '#E0E0E0' }
        }
      }
    }
  });
  await applyTheme(darkTheme);
});
 
const toggleTheme = () => {
  currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light';
  setTheme(currentTheme.value);
};
</script>
 
<template>
  <button class="btn btn-primary" @click="toggleTheme">
    Toggle Theme
  </button>
</template>

Angular Integration

import { Component, OnInit } from '@angular/core';
import { createTheme, applyTheme, setTheme } from '@web-loom/design-core/utils';
 
@Component({
  selector: 'app-theme-toggle',
  template: `
    <button class="btn btn-primary" (click)="toggleTheme()">
      Toggle Theme
    </button>
  `,
  styles: []
})
export class ThemeToggleComponent implements OnInit {
  currentTheme = 'light';
  
  async ngOnInit() {
    const darkTheme = createTheme('dark', {
      color: {
        themed: {
          dark: {
            background: { value: '#121212' },
            text: { value: '#E0E0E0' }
          }
        }
      }
    });
    await applyTheme(darkTheme);
  }
  
  toggleTheme() {
    this.currentTheme = this.currentTheme === 'light' ? 'dark' : 'light';
    setTheme(this.currentTheme);
  }
}

Vanilla JavaScript Integration

import { createTheme, applyTheme, setTheme } from '@web-loom/design-core/utils';
 
let currentTheme = 'light';
 
async function setupThemeToggle() {
  // Setup dark theme
  const darkTheme = createTheme('dark', {
    color: {
      themed: {
        dark: {
          background: { value: '#121212' },
          text: { value: '#E0E0E0' }
        }
      }
    }
  });
  await applyTheme(darkTheme);
  
  // Add event listener
  const button = document.getElementById('theme-toggle');
  button.addEventListener('click', () => {
    currentTheme = currentTheme === 'light' ? 'dark' : 'light';
    setTheme(currentTheme);
  });
}
 
setupThemeToggle();

Notice that the theming logic is identical across all frameworks. Only the component lifecycle and event handling differ. The design system itself is completely framework-agnostic.

Benefits of Framework-Agnostic Design Systems

Using CSS Custom Properties and design tokens provides several key benefits for MVVM applications:

1. Single Source of Truth

All visual design decisions live in one place—the token definitions. When you change a color or spacing value, it updates everywhere automatically. This is the same principle as ViewModels providing a single source of truth for presentation logic.

2. Framework Independence

The design system works identically in React, Vue, Angular, Lit, and vanilla JavaScript. You can migrate between frameworks without rewriting styles. This aligns perfectly with MVVM's goal of framework-independent business logic.

3. Runtime Theming

CSS Custom Properties can be changed at runtime without recompiling CSS. This enables:

  • Light/dark mode switching
  • User-customizable themes
  • A/B testing different color schemes
  • Accessibility themes (high contrast, large text)

4. Reduced Bundle Size

Instead of duplicating color values and spacing units throughout your CSS, you reference variables. This reduces CSS file size and improves caching.

5. Developer Experience

CSS variables provide excellent DevTools support. You can inspect and modify values in real-time, making debugging and experimentation easier.

6. Design Tool Integration

Using the DTCG standard format enables integration with design tools like Figma and Sketch. Designers can export tokens directly from their tools, and developers can import them into code.

Alternative Approaches

While CSS Custom Properties are the most flexible approach for web applications, other patterns exist:

1. CSS-in-JS with Theme Objects

Libraries like styled-components and Emotion use JavaScript theme objects:

const theme = {
  colors: {
    primary: '#1E40AF',
    background: '#FFFFFF'
  },
  spacing: {
    small: '8px',
    medium: '16px'
  }
};
 
const Button = styled.button`
  background: ${props => props.theme.colors.primary};
  padding: ${props => props.theme.spacing.medium};
`;

Pros: Type-safe, scoped to components, dynamic based on props Cons: Framework-specific (React), larger bundle size, no DevTools inspection

2. Sass Variables

Sass provides compile-time variables:

$color-primary: #1E40AF;
$spacing-medium: 16px;
 
.button {
  background: $color-primary;
  padding: $spacing-medium;
}

Pros: Mature ecosystem, powerful functions and mixins Cons: Compile-time only (no runtime theming), requires build step

3. Tailwind CSS

Tailwind uses utility classes with a configuration file:

// tailwind.config.js
module.exports = {
  theme: {
    colors: {
      primary: '#1E40AF'
    },
    spacing: {
      4: '16px'
    }
  }
};
<button class="bg-primary p-4">Button</button>

Pros: Utility-first, excellent DX, purges unused styles Cons: Large HTML class strings, harder to theme dynamically

4. Design Token Build Tools

Tools like Style Dictionary and Theo transform tokens into multiple formats:

// tokens.json
{
  "color": {
    "primary": { "value": "#1E40AF" }
  }
}
# Generate CSS, JS, iOS, Android formats
style-dictionary build

Pros: Multi-platform support, flexible transformations Cons: Build-time only, requires tooling setup

Choosing an Approach

For MVVM applications with multiple framework implementations, CSS Custom Properties with design tokens is the most flexible approach because:

  1. It works across all frameworks without modification
  2. It supports runtime theming for light/dark mode
  3. It has zero runtime dependencies
  4. It integrates with standard CSS workflows
  5. It provides excellent browser DevTools support

Use CSS-in-JS if you're committed to a single framework (React) and want type-safe, component-scoped styles. Use Sass if you need compile-time transformations and don't need runtime theming. Use Tailwind if you prefer utility-first CSS and can accept its tradeoffs.

Design Systems in the GreenWatch Application

Let's see how design tokens apply to the GreenWatch greenhouse monitoring system we've built throughout this book.

Token Organization for GreenWatch

The application uses semantic tokens that map to domain concepts:

{
  "color": {
    "sensor": {
      "temperature": {
        "normal": { "value": "{color.base.primary.value}" },
        "warning": { "value": "{color.base.warning.value}" },
        "critical": { "value": "{color.base.danger.value}" }
      },
      "humidity": {
        "normal": { "value": "#3B82F6" },
        "low": { "value": "#F59E0B" },
        "high": { "value": "#EF4444" }
      }
    },
    "alert": {
      "background": { "value": "{color.base.danger.value}" },
      "text": { "value": "{color.neutral.white.value}" }
    }
  }
}

These semantic tokens make the intent clear: color.sensor.temperature.critical is more meaningful than color.base.danger.

Styling Sensor Components

The sensor card component uses design tokens consistently:

.sensor-card {
  background: var(--color-neutral-white);
  border: 1px solid var(--color-neutral-gray-200);
  padding: var(--spacing-4);
  border-radius: var(--radii-md);
  box-shadow: var(--shadows-sm);
}
 
.sensor-reading {
  font-size: var(--typography-fontSize-lg);
  font-weight: var(--typography-fontWeight-semibold);
  margin-bottom: var(--spacing-2);
}
 
.sensor-reading--normal {
  color: var(--color-sensor-temperature-normal);
}
 
.sensor-reading--warning {
  color: var(--color-sensor-temperature-warning);
}
 
.sensor-reading--critical {
  color: var(--color-sensor-temperature-critical);
}

This works identically in the React, Vue, Angular, Lit, and vanilla JavaScript implementations of GreenWatch.

Dark Mode for GreenWatch

The dark theme overrides background and text colors while maintaining semantic sensor colors:

const greenWatchDarkTheme = createTheme('dark', {
  color: {
    neutral: {
      white: { value: '#FFFFFF' },
      gray: {
        900: { value: '#111827' },
        800: { value: '#1F2937' },
        200: { value: '#E5E7EB' }
      }
    },
    themed: {
      dark: {
        background: { value: '#0F172A' },  // Darker blue-gray
        text: { value: '#F1F5F9' }
      }
    }
  },
  shadows: {
    sm: { value: '0 1px 2px rgba(255, 255, 255, 0.05)' },
    md: { value: '0 4px 6px rgba(255, 255, 255, 0.1)' }
  }
});

When activated, the entire GreenWatch UI switches to dark mode—across all framework implementations—without any component-level changes.

ViewModel Integration

ViewModels can access design tokens when they need to compute styles dynamically:

import { getTokenValue } from '@web-loom/design-core/utils';
 
class SensorViewModel extends BaseViewModel {
  readonly statusColor$ = this.reading$.pipe(
    map(async (reading) => {
      if (reading.temperature > this.criticalThreshold) {
        return await getTokenValue('color.sensor.temperature.critical');
      } else if (reading.temperature > this.warningThreshold) {
        return await getTokenValue('color.sensor.temperature.warning');
      } else {
        return await getTokenValue('color.sensor.temperature.normal');
      }
    })
  );
}

This keeps color logic centralized in the design system while allowing ViewModels to make dynamic styling decisions based on state.

Best Practices

Based on the patterns we've explored, here are best practices for design systems in MVVM applications:

1. Use Semantic Token Names

Prefer semantic names over primitive values:

/* ✅ Good: Semantic */
.alert {
  background: var(--color-alert-background);
  color: var(--color-alert-text);
}
 
/* ❌ Avoid: Too specific */
.alert {
  background: var(--color-red-500);
  color: var(--color-white);
}

Semantic names make intent clear and enable theme-specific overrides.

2. Load Tokens Early

Inject CSS variables as early as possible in your application lifecycle:

// main.ts or index.ts
import { generateCssVariablesString } from '@web-loom/design-core/utils';
 
async function setupDesignSystem() {
  const cssVars = await generateCssVariablesString(':root');
  const style = document.createElement('style');
  style.id = 'design-tokens';
  style.textContent = cssVars;
  document.head.appendChild(style);
}
 
setupDesignSystem();

This ensures tokens are available before any components render.

3. Provide Fallback Values

Always provide fallback values for CSS variables in case tokens aren't loaded:

.button {
  background: var(--color-base-primary, #1E40AF);
  padding: var(--spacing-4, 16px);
}

This makes your application more resilient to loading failures.

4. Test Across Themes

Test your application in all supported themes to ensure visual consistency:

describe('SensorCard', () => {
  it('renders correctly in light theme', () => {
    setTheme('light');
    const { container } = render(<SensorCard />);
    expect(container).toMatchSnapshot();
  });
  
  it('renders correctly in dark theme', () => {
    setTheme('dark');
    const { container } = render(<SensorCard />);
    expect(container).toMatchSnapshot();
  });
});

5. Document Token Usage

Document which tokens are used for which purposes:

/**
 * Sensor card component
 * 
 * Design tokens used:
 * - color.neutral.white: Card background
 * - color.neutral.gray.200: Card border
 * - spacing.4: Card padding
 * - radii.md: Card border radius
 * - shadows.sm: Card elevation
 */

This helps designers and developers understand token dependencies.

6. Version Your Tokens

Treat design tokens as a versioned API. Use semantic versioning and document breaking changes:

{
  "version": "2.0.0",
  "changelog": [
    "2.0.0: Renamed color.primary to color.base.primary (breaking)",
    "1.1.0: Added color.sensor.* semantic tokens",
    "1.0.0: Initial release"
  ]
}

Key Takeaways

Design systems and theming are critical for maintaining visual consistency in MVVM applications, especially when implementing the same ViewModels across multiple frameworks. The key insights from this chapter:

  1. Design tokens provide a single source of truth for visual design decisions, just as ViewModels provide a single source of truth for presentation logic.

  2. CSS Custom Properties enable framework-agnostic styling that works identically in React, Vue, Angular, Lit, and vanilla JavaScript.

  3. Token references create semantic layers that make design systems maintainable and flexible. Changing a base token automatically updates all derived tokens.

  4. Dynamic theming with CSS variables enables runtime theme switching without CSS recompilation, supporting light/dark mode and user customization.

  5. The DTCG standard format enables interoperability between design tools and code, bridging the designer-developer gap.

  6. Framework-agnostic design systems align with MVVM principles by separating visual concerns from business logic and enabling View layer flexibility.

The design-core library demonstrates these patterns concretely, but the principles apply to any design token system. Whether you use design-core, Style Dictionary, Theo, or build your own implementation, the core concepts remain the same: centralize design decisions, generate platform-specific outputs, and leverage CSS Custom Properties for runtime flexibility.

In the next chapter, we'll bring together everything we've learned—ViewModels, Models, framework implementations, supporting libraries, and design systems—to examine complete case studies of MVVM applications in production.

Further Reading


Code Examples Source:

  • Token definitions: packages/design-core/src/tokens/
  • Token utilities: packages/design-core/src/utils/tokens.ts
  • CSS variable generation: packages/design-core/src/utils/cssVariables.ts
  • Theming API: packages/design-core/src/utils/theme.ts
Web Loom logo
Copyright © Web Loom. All rights reserved.