When CSS Variables Alone Are Not Enough

In the previous article on theme systems, we established that CSS should be the single source of truth for theme colors. For 95% of applications, you define colors in CSS using :root[data-theme] selectors and JavaScript simply sets the data-theme attribute. The browser handles everything else natively and efficiently.

But what about the other 5%? Some applications genuinely need JavaScript to set CSS custom properties at runtime. This article covers those scenarios in depth with production-ready patterns.

Prerequisites

This article assumes you’ve read Building Production-Ready Theme Systems with Context and understand the CSS-first approach. The patterns here extend that foundation for specialized use cases.


When JavaScript CSS Properties ARE Needed

The CSS-first approach breaks down when colors aren’t known at build time. To truly understand why JavaScript becomes necessary, we need to examine the fundamental nature of CSS and its limitations.

Understanding the Build-Time vs. Runtime Boundary

Every web application has two distinct phases: build time (when code is compiled and bundled) and runtime (when the application executes in the browser). This distinction is crucial for understanding when JavaScript CSS properties become necessary.

Build time is when your CSS files are processed. Tools like Vite, PostCSS, or Tailwind run during this phase. Any color defined in CSS exists permanently in the stylesheet that ships to users:

/* Build-time colors: These exist BEFORE users ever visit your site */
:root {
	--color-primary: #3b82f6; /* Hardcoded at build time */
	--color-primary-hover: #2563eb; /* Hardcoded at build time */
}

:root[data-theme='dark'] {
	--color-primary: #60a5fa; /* Also hardcoded at build time */
	--color-primary-hover: #93c5fd; /* Also hardcoded at build time */
}

The browser receives these values as static text. When JavaScript toggles data-theme, it’s merely switching between pre-defined values that already exist in the stylesheet. The colors themselves never change—only which set is active.

Runtime is when the application runs in the user’s browser. This is when:

  • Users interact with forms and color pickers
  • API calls return data from servers
  • JavaScript performs calculations
  • The application reads from localStorage or cookies

The critical insight is that CSS cannot execute logic at runtime. CSS is declarative—it describes what should happen given certain conditions, but it cannot fetch data, perform calculations, or respond to arbitrary user input.

Loading diagram...

With this foundation, let’s examine five scenarios where colors genuinely cannot be known at build time, making JavaScript CSS properties necessary.


Case 1: User-Customizable Colors — The Infinity Problem

The Scenario: Your application includes a color picker that lets users choose their own accent color. They click, select #e11d48, and expect the entire UI to reflect their choice.

Why This Fundamentally Cannot Work in Pure CSS:

CSS attribute selectors can only match values you explicitly define. To support arbitrary user colors, you’d need to anticipate every possible value:

/* The mathematical impossibility */
:root[data-user-color='#000000'] {
	--color-primary: #000000;
}
:root[data-user-color='#000001'] {
	--color-primary: #000001;
}
:root[data-user-color='#000002'] {
	--color-primary: #000002;
}
/* ... 16,777,213 more rules ... */
:root[data-user-color='#ffffff'] {
	--color-primary: #ffffff;
}

This would generate a CSS file of approximately 1.3 gigabytes. Even if you limited users to a curated palette of 100 colors, you’ve removed the customization that makes the feature valuable. Users expect to match their brand, their favorite color, or their accessibility needs—not choose from your predetermined list.

The Deeper Problem — Derived Colors:

A single user color typically needs multiple derived values:

User picks: #e11d48 (rose-600)

You need:
  --color-primary:        #e11d48  (the base)
  --color-primary-hover:  #be123c  (10% darker for hover)
  --color-primary-active: #9f1239  (20% darker for active)
  --color-primary-light:  #fda4af  (for backgrounds)
  --color-primary-fg:     #ffffff  (text color with good contrast)

CSS cannot calculate these. You can’t write:

/* This is not valid CSS */
:root {
	--color-primary-hover: darken(var(--color-primary), 10%);
}

While CSS has color-mix() in modern browsers, it still requires the base color to exist first—it can’t read from an input field.

Real-World Applications:

Application TypeWhy Users Need Custom Colors
Personal dashboardsMatch workspace to preferences, reduce eye strain
Note-taking appsColor-code notebooks, folders, tags for organization
Social profilesExpress personality, match personal brand
Accessibility toolsCustom high-contrast combinations for vision needs
Design toolsPreview work in different color contexts
Gaming platformsTeam colors, clan colors, personal flair

The JavaScript Solution:

JavaScript bridges the gap between user input and CSS:

// 1. User interacts with color picker (runtime event)
const userColor = '#e11d48'

// 2. JavaScript computes derived colors (runtime calculation)
const palette = {
	base: userColor,
	hover: darken(userColor, 10), // Your utility function
	light: lighten(userColor, 40),
	foreground: getContrastColor(userColor)
}

// 3. JavaScript injects into CSS (runtime mutation)
document.documentElement.style.setProperty('--color-primary', palette.base)
document.documentElement.style.setProperty('--color-primary-hover', palette.hover)
document.documentElement.style.setProperty('--color-primary-light', palette.light)
document.documentElement.style.setProperty('--color-primary-foreground', palette.foreground)

// 4. CSS uses the values normally
// .button { background: var(--color-primary); }

The color picker generates a value. JavaScript transforms it into a palette. CSS consumes it. Each layer does what it’s designed for.


Case 2: API-Driven Themes — The External Data Problem

The Scenario: Your application’s brand colors are stored in a database, managed through a CMS, or fetched from a brand management API. A marketing manager updates the primary color from #3b82f6 to #6366f1, and all users see the change without any code deployment.

Why This Fundamentally Cannot Work in Pure CSS:

CSS files are static assets. They’re written by developers, processed by build tools, and deployed to CDNs. They cannot:

  1. Make HTTP requests to fetch data
  2. Read from databases
  3. Parse JSON responses
  4. Update themselves based on external state
/* This is not valid CSS — CSS cannot fetch data */
:root {
	--color-primary: fetch('/api/brand/colors') .primary;
}

The colors don’t exist in your codebase. They exist in a database somewhere else entirely. Your CSS file ships with placeholder values or defaults, but the “real” colors live externally.

The Organizational Problem:

Consider a typical company structure:

┌─────────────────────────────────────────────────────────┐
│                    Brand Management                     │
├─────────────────────────────────────────────────────────┤
│  Marketing Team                                         │
│  ├── Updates brand colors seasonally                    │
│  ├── Runs A/B tests on color schemes                    │
│  ├── Manages sub-brand variations                       │
│  └── Has NO access to codebase                          │
│                                                         │
│  Design System Team                                     │
│  ├── Maintains color tokens in Figma                    │
│  ├── Syncs tokens to brand management API               │
│  └── Wants changes reflected immediately                │
│                                                         │
│  Development Team                                       │
│  ├── Should NOT be bottleneck for color changes         │
│  ├── Cannot deploy for every brand update               │
│  └── Needs decoupled, API-driven approach               │
└─────────────────────────────────────────────────────────┘

If colors are hardcoded in CSS, every change requires:

  1. Developer updates CSS file
  2. Code review and approval
  3. CI/CD pipeline runs
  4. Deployment to production
  5. CDN cache invalidation

This process takes hours or days. With API-driven themes, the marketing manager clicks “Save” in the CMS, and users see the new color within minutes.

Real-World Scenarios:

ScenarioData SourceWhy It Can’t Be Static
CMS-managed marketing sitesContentful, Sanity, StrapiContent editors control branding
Multi-brand applicationsBrand databaseOne codebase, many brands (Coca-Cola family)
Partner portalsPartner APIEach reseller has custom branding
A/B testingExperiment serviceDifferent users see different colors
Seasonal themesCampaign databaseHoliday themes without deployment
Enterprise customizationCustomer settingsEach enterprise client has brand colors

The Data Flow:

Loading diagram...

The JavaScript Solution:

// The colors exist in a database, not your code
interface BrandColors {
	primary: string
	secondary: string
	accent: string
}

async function loadBrandColors(): Promise<void> {
	// 1. Fetch from external source (runtime network request)
	const response = await fetch('/api/brand/colors')
	const colors: BrandColors = await response.json()
	// Returns: { primary: '#6366f1', secondary: '#8b5cf6', accent: '#ec4899' }

	// 2. Inject into CSS (runtime mutation)
	const root = document.documentElement
	root.style.setProperty('--color-primary', colors.primary)
	root.style.setProperty('--color-secondary', colors.secondary)
	root.style.setProperty('--color-accent', colors.accent)

	// 3. CSS uses values that didn't exist until this moment
}

Caching Strategy:

Since API calls introduce latency, a robust solution uses caching:

async function loadBrandColorsWithCache(): Promise<void> {
	// 1. Apply cached version IMMEDIATELY (no flash)
	const cached = localStorage.getItem('brand-colors')
	if (cached) {
		applyColors(JSON.parse(cached))
	}

	// 2. Fetch fresh data in background
	const fresh = await fetchBrandColors()

	// 3. Update if different
	if (JSON.stringify(fresh) !== cached) {
		applyColors(fresh)
		localStorage.setItem('brand-colors', JSON.stringify(fresh))
	}
}

This pattern ensures users never see a flash of default colors on repeat visits while still getting updates when brand colors change.


Case 3: Runtime Color Computation — The Mathematics Problem

The Scenario: Given a single base color, you need to mathematically generate an entire palette: tints, shades, complementary colors, and accessible text colors. This is common in design systems, theme generators, and data visualization.

Why This Fundamentally Cannot Work in Pure CSS:

CSS is not a programming language. It cannot:

  1. Convert color spaces — Transforming hex to HSL to LAB requires mathematical functions CSS doesn’t have
  2. Perform iterative calculations — Generating 11 shades (50-950) requires loops
  3. Calculate contrast ratios — WCAG compliance requires luminance calculations
  4. Apply color theory — Complementary, analogous, and triadic schemes need hue rotation

Modern CSS has color-mix(), but it’s limited:

/* CSS color-mix can blend two colors */
.element {
	background: color-mix(in srgb, #3b82f6 70%, white);
}

/* But it CANNOT: */
/* - Generate a full Tailwind-style scale */
/* - Calculate if text should be white or black */
/* - Create complementary colors */
/* - Ensure WCAG compliance */

The Mathematical Complexity:

Consider what generating a single Tailwind-style color scale requires:

Input: #6366f1 (indigo-500)

Step 1: Convert to LAB color space (perceptually uniform)
  L: 52.93, a: 46.78, b: -77.31

Step 2: Interpolate toward white for lighter shades
  50:  L: 97.2, a: 2.1, b: -3.5   → #eef2ff
  100: L: 94.3, a: 4.8, b: -8.2   → #e0e7ff
  200: L: 88.1, a: 10.2, b: -18.4 → #c7d2fe
  ... (continue for 300, 400)

Step 3: Interpolate toward black for darker shades
  600: L: 43.8, a: 52.1, b: -82.3 → #4f46e5
  700: L: 35.2, a: 54.8, b: -78.9 → #4338ca
  ... (continue for 800, 900, 950)

Step 4: Convert each LAB value back to hex

Step 5: Calculate foreground color
  Luminance of #6366f1: 0.21
  Contrast with white: 4.56:1 ✓ (passes WCAG AA)
  Contrast with black: 4.58:1 ✓
  → Use white (slightly better contrast)

This is approximately 50 mathematical operations per color scale. CSS cannot perform any of them.

Why LAB Color Space Matters:

Human perception of color is non-linear. RGB and HSL create uneven-looking scales:

RGB interpolation (looks wrong):
#ffffff → #6366f1 → #000000
  50:  Very washed out
  200: Still too light
  400: Sudden jump in saturation
  700: Muddy and dark
  950: Pure black too early

LAB interpolation (looks right):
#ffffff → #6366f1 → #000000
  50:  Barely tinted white
  200: Soft, balanced tint
  400: Clearly colored but light
  700: Rich, deep color
  950: Very dark but still colored

Libraries like Chroma.js handle this complexity:

import chroma from 'chroma-js'

// Generate perceptually uniform scale
const scale = chroma
	.scale(['#ffffff', '#6366f1', '#000000'])
	.mode('lab') // Use LAB color space
	.colors(11) // Generate 11 colors

// Calculate accessible text color
const bg = chroma('#6366f1')
const whiteContrast = chroma.contrast(bg, 'white') // 4.56
const blackContrast = chroma.contrast(bg, 'black') // 4.58
const textColor = whiteContrast > blackContrast ? 'white' : 'black'

// Generate complementary color (180° hue rotation)
const complement = chroma('#6366f1').set('hsl.h', '+180') // #f1ef63

Real-World Applications:

ApplicationColor Math Required
Design system generatorsFull palette from brand color
Data visualizationGradient scales for heatmaps, choropleth maps
Accessibility checkersWCAG contrast ratio calculations
Theme buildersUser picks one color, app generates cohesive scheme
Progress indicatorsColor transitions based on percentage (red → yellow → green)
Sentiment analysis UIsColor mapping for positive/negative scores

The JavaScript Solution:

import chroma from 'chroma-js'

function generateAndApplyPalette(baseColor: string): void {
	const base = chroma(baseColor)

	// Generate scale using perceptually uniform color space
	const lightScale = chroma.scale(['#ffffff', base]).mode('lab').colors(6)
	const darkScale = chroma.scale([base, '#000000']).mode('lab').colors(6)

	// Calculate accessible foreground
	const foreground = chroma.contrast(base, 'white') >= 4.5 ? '#ffffff' : '#0f172a'

	// Apply all computed values to CSS
	const root = document.documentElement
	root.style.setProperty('--color-primary-50', lightScale[1])
	root.style.setProperty('--color-primary-100', lightScale[2])
	root.style.setProperty('--color-primary-200', lightScale[3])
	root.style.setProperty('--color-primary-300', lightScale[4])
	root.style.setProperty('--color-primary-400', lightScale[5])
	root.style.setProperty('--color-primary-500', base.hex())
	root.style.setProperty('--color-primary-600', darkScale[1])
	root.style.setProperty('--color-primary-700', darkScale[2])
	root.style.setProperty('--color-primary-800', darkScale[3])
	root.style.setProperty('--color-primary-900', darkScale[4])
	root.style.setProperty('--color-primary-950', darkScale[5])
	root.style.setProperty('--color-primary-foreground', foreground)
}

Case 4: Third-Party Library Integration — The JavaScript-Only World Problem

The Scenario: You’re using Chart.js, D3.js, Three.js, or any Canvas-based library. These libraries need color values as JavaScript strings. They cannot read CSS variables directly.

Why This Fundamentally Cannot Work with CSS Variables Alone:

Third-party libraries operate in JavaScript, not CSS. When you configure Chart.js:

new Chart(canvas, {
	data: {
		datasets: [
			{
				borderColor: '#3b82f6' // ✅ This works
			}
		]
	}
})

The library takes that string and passes it directly to the Canvas 2D API:

// Inside Chart.js (simplified)
ctx.strokeStyle = config.borderColor // Sets canvas stroke color
ctx.stroke() // Draws the line

The Canvas API expects a color string like '#3b82f6' or 'rgb(59, 130, 246)'. It does not understand CSS syntax:

ctx.strokeStyle = 'var(--color-primary)' // ❌ Draws nothing (invalid color)

The Canvas API was designed before CSS custom properties existed. It has no mechanism to resolve them.

The Same Problem Exists Across Many Libraries:

LibraryWhy CSS Variables Don’t Work
Chart.jsPasses colors directly to Canvas 2D context
D3.jsSets SVG attributes as strings, not CSS
Three.jsWebGL requires numeric color values (0x3b82f6)
Mapbox/LeafletGeoJSON styling uses JavaScript objects
GSAP/Anime.jsInterpolates between color values in JS
jsPDFPDF specification requires explicit colors
Fabric.jsCanvas manipulation library
PixiJSWebGL-based rendering

The Bridge Pattern:

You need to read the computed CSS values and pass them to JavaScript libraries:

// 1. Read what CSS has computed
function getThemeColors(): Record<string, string> {
	const styles = getComputedStyle(document.documentElement)
	return {
		primary: styles.getPropertyValue('--color-primary').trim(),
		secondary: styles.getPropertyValue('--color-secondary').trim(),
		surface: styles.getPropertyValue('--color-surface').trim(),
		foreground: styles.getPropertyValue('--color-foreground').trim(),
		border: styles.getPropertyValue('--color-border').trim()
	}
}

// 2. Pass resolved values to library
const colors = getThemeColors()

new Chart(canvas, {
	data: {
		datasets: [
			{
				borderColor: colors.primary, // ✅ Resolved hex value
				backgroundColor: colors.surface // ✅ Resolved hex value
			}
		]
	},
	options: {
		scales: {
			x: {
				grid: { color: colors.border } // ✅ Resolved hex value
			}
		}
	}
})

The Theme Change Problem:

When your theme changes (light → dark), the CSS variables update automatically. But libraries that already received the old values don’t know about the change:

Initial state:
  CSS: --color-primary: #3b82f6 (light theme blue)
  Chart.js has: borderColor: '#3b82f6'

User switches to dark theme:
  CSS: --color-primary: #60a5fa (dark theme blue)
  Chart.js still has: borderColor: '#3b82f6' ← STALE!

You must detect theme changes and recreate or update the library instance:

$effect(() => {
	// Access theme.mode to create reactive dependency
	const currentMode = theme.mode

	// Read fresh colors after theme change
	const colors = getThemeColors()

	// Destroy old chart
	if (chart) {
		chart.destroy()
	}

	// Create new chart with updated colors
	chart = new Chart(canvas, {
		// ... config using new colors
	})

	// Cleanup on effect re-run or component unmount
	return () => {
		if (chart) chart.destroy()
	}
})

Why This Pattern Is Necessary:

Loading diagram...

Case 5: White-Label SaaS Multi-Tenant Theming — The Unknown Identity Problem

The Scenario: You’re building a SaaS product where each customer (tenant) has their own branding. When someone visits acme.yourapp.com, they see Acme’s blue. When someone visits globex.yourapp.com, they see Globex’s green. Same codebase, different appearances.

Why This Fundamentally Cannot Work in Pure CSS:

You don’t know which tenant is visiting until runtime. The tenant’s identity comes from:

  • Subdomain: acme.yourapp.com → Tenant: “acme”
  • Path: yourapp.com/org/acme → Tenant: “acme”
  • Authentication: User logs in → Belongs to “acme” organization
  • Cookie/Header: X-Tenant-ID: acme

CSS cannot read any of these. CSS doesn’t know what URL the user typed or who they authenticated as.

The Scale Problem:

You could theoretically pre-generate CSS for every tenant:

:root[data-tenant='acme'] {
	--color-primary: #3b82f6;
	--color-header-bg: #1e3a5f;
}

:root[data-tenant='globex'] {
	--color-primary: #22c55e;
	--color-header-bg: #14532d;
}

:root[data-tenant='initech'] {
	--color-primary: #8b5cf6;
	--color-header-bg: #4c1d95;
}

/* ... 997 more tenants ... */

This approach has catastrophic problems:

ProblemImpact
CSS bundle sizeGrows linearly with tenants. 1000 tenants = massive file
Deployment couplingNew tenant or color change = code deployment
Self-service impossibleTenants can’t customize their own branding
Build timeSlows as tenant count grows
Cache invalidationAny tenant change invalidates everyone’s cache

The Business Reality:

SaaS products often have these requirements:

  1. Tenant self-service: Admins customize branding without contacting support
  2. Instant updates: Changes visible immediately, not after deployment
  3. Unlimited scale: Support thousands of tenants without code changes
  4. Complete isolation: One tenant’s branding never leaks to another
  5. Beyond colors: Custom logos, favicons, fonts, and page titles

Real-World Applications:

Product TypeWhy Multi-Tenant ThemingExamples
B2B SaaSEach company sees their brandSlack, Notion, Linear
E-commerce platformsEach merchant has branded storeShopify, BigCommerce
LMS platformsSchools see institutional brandingCanvas, Moodle
Healthcare portalsClinics have branded patient portalsEpic MyChart
Agency toolsWhite-label for agency clientsMany marketing tools
Franchise softwareEach location has local brandingPOS systems

The Architecture:

Loading diagram...

The SSR Critical Path:

Without server-side handling, users see a flash of default colors:

Without SSR:
  1. Browser requests page
  2. Server sends HTML with DEFAULT colors in CSS
  3. Page renders with DEFAULT colors
  4. JavaScript loads
  5. JavaScript detects tenant
  6. JavaScript fetches branding
  7. JavaScript applies colors
  8. Page FLASHES to tenant colors  ← BAD UX

With SSR:
  1. Browser requests page
  2. Server detects tenant from subdomain
  3. Server fetches branding (cached in Redis)
  4. Server injects tenant colors in <style> tag
  5. Page renders with CORRECT colors immediately  ← GOOD UX
  6. JavaScript hydrates and takes over

The JavaScript Solution (Complete):

// src/hooks.server.ts — Server-side tenant detection
import type { Handle } from '@sveltejs/kit'

export const handle: Handle = async ({ event, resolve }) => {
	// 1. Extract tenant from subdomain
	const host = event.request.headers.get('host') ?? ''
	const subdomain = host.split('.')[0]

	// 2. Skip non-tenant subdomains
	if (['www', 'app', 'api'].includes(subdomain)) {
		return resolve(event)
	}

	// 3. Fetch tenant branding (with caching)
	const branding = await getTenantBranding(subdomain)

	if (!branding) {
		return resolve(event) // Unknown tenant, use defaults
	}

	// 4. Inject branding into HTML
	return resolve(event, {
		transformPageChunk: ({ html }) => {
			const styles = `
        <style id="tenant-theme">
          :root {
            --color-primary: ${branding.primary};
            --color-primary-hover: ${branding.primaryHover};
            --color-header-bg: ${branding.headerBg};
            --color-header-fg: ${branding.headerFg};
          }
        </style>
      `
			return html.replace('</head>', `${styles}</head>`)
		}
	})
}

// Caching layer
const brandingCache = new Map<string, TenantBranding>()

async function getTenantBranding(slug: string): Promise<TenantBranding | null> {
	// Check memory cache first
	if (brandingCache.has(slug)) {
		return brandingCache.get(slug)!
	}

	// Fetch from database
	const branding = await db.tenants.findUnique({
		where: { slug },
		select: { branding: true }
	})

	if (branding) {
		brandingCache.set(slug, branding) // Cache for future requests
	}

	return branding
}

Summary: The Decision Framework

Use this flowchart to decide whether you need JavaScript CSS properties:

Loading diagram...
QuestionIf Yes →If No →
Are all colors defined in your CSS files?CSS-firstNeed JavaScript
Can marketing change colors without deployment?Need JavaScriptCSS-first is fine
Do users pick their own colors?Need JavaScriptCSS-first is fine
Do you generate palettes from one base color?Need JavaScriptCSS-first is fine
Do Chart.js/D3/Canvas need your theme colors?Need JavaScriptCSS-first is fine
Does each customer see different branding?Need JavaScriptCSS-first is fine
Rule of Thumb

If colors are known at build time → Define them in CSS.

If colors are determined at runtime → Use JavaScript to set them.

Most apps (approximately 95%) have static color palettes, so CSS-first is the default choice. Only use JavaScript CSS properties when you genuinely need runtime customization.


Core Utilities

Before diving into specific use cases, let’s establish the foundational utilities that all patterns share:

// src/lib/theme/dynamic-colors.svelte.ts
import { browser } from '$app/environment'

/**
 * Applies dynamic colors to CSS custom properties.
 * Use this ONLY for runtime-determined colors.
 */
export function applyDynamicColors(colors: Record<string, string>): void {
	if (!browser) return

	const root = document.documentElement
	for (const [key, value] of Object.entries(colors)) {
		root.style.setProperty(`--color-${key}`, value)
	}
}

/**
 * Removes dynamic colors, reverting to CSS defaults.
 * CSS will fall back to values defined in your stylesheet.
 */
export function clearDynamicColors(keys: string[]): void {
	if (!browser) return

	const root = document.documentElement
	for (const key of keys) {
		root.style.removeProperty(`--color-${key}`)
	}
}

/**
 * Reads current CSS custom property values.
 * Useful for passing colors to third-party libraries.
 */
export function getComputedColors(keys: string[]): Record<string, string> {
	if (!browser) return {}

	const styles = getComputedStyle(document.documentElement)
	const colors: Record<string, string> = {}

	for (const key of keys) {
		colors[key] = styles.getPropertyValue(`--color-${key}`).trim()
	}

	return colors
}

These utilities form the foundation for all dynamic theming patterns. Notice they’re defensive about SSR—always checking for browser before accessing document.


Pattern 1: User-Customizable Color Picker

The most common runtime theming need is letting users pick their own accent or brand color. This requires generating a complete palette from a single color choice.

Color Math Utilities

First, let’s build some pure functions for color manipulation:

// src/lib/theme/color-utils.ts

/**
 * Converts hex color to RGB components.
 */
export function hexToRgb(hex: string): { r: number; g: number; b: number } {
	const cleanHex = hex.replace('#', '')
	return {
		r: parseInt(cleanHex.slice(0, 2), 16),
		g: parseInt(cleanHex.slice(2, 4), 16),
		b: parseInt(cleanHex.slice(4, 6), 16)
	}
}

/**
 * Converts RGB components to hex color.
 */
export function rgbToHex(r: number, g: number, b: number): string {
	const toHex = (n: number) =>
		Math.max(0, Math.min(255, Math.round(n)))
			.toString(16)
			.padStart(2, '0')
	return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}

/**
 * Adjusts brightness of a hex color.
 * Positive percent lightens, negative darkens.
 */
export function adjustBrightness(hex: string, percent: number): string {
	const { r, g, b } = hexToRgb(hex)
	const amount = Math.round(2.55 * percent)
	return rgbToHex(r + amount, g + amount, b + amount)
}

/**
 * Calculates relative luminance for contrast calculations.
 * Returns value between 0 (black) and 1 (white).
 */
export function getLuminance(hex: string): number {
	const { r, g, b } = hexToRgb(hex)
	return (0.299 * r + 0.587 * g + 0.114 * b) / 255
}

/**
 * Returns black or white depending on which has better contrast.
 */
export function getContrastColor(bgHex: string): string {
	return getLuminance(bgHex) > 0.5 ? '#000000' : '#ffffff'
}

/**
 * Generates a simple palette from a base color.
 */
export function generateSimplePalette(baseColor: string) {
	return {
		light: adjustBrightness(baseColor, 30),
		base: baseColor,
		dark: adjustBrightness(baseColor, -20),
		foreground: getContrastColor(baseColor)
	}
}

The Color Picker Component

Now let’s build a complete color picker that applies changes in real-time:

<!-- src/lib/components/ColorCustomizer.svelte -->
<script lang="ts">
	import { applyDynamicColors, clearDynamicColors } from '$lib/theme/dynamic-colors.svelte.js'
	import { generateSimplePalette, getContrastColor } from '$lib/theme/color-utils.js'
	import { browser } from '$app/environment'

	interface Props {
		/** Initial color value */
		initialColor?: string
		/** Storage key for persistence */
		storageKey?: string
		/** Callback when color changes */
		onchange?: (color: string) => void
	}

	let { initialColor = '#3b82f6', storageKey = 'user-accent-color', onchange }: Props = $props()

	// Load saved color or use initial
	function loadSavedColor(): string {
		if (!browser) return initialColor
		return localStorage.getItem(storageKey) ?? initialColor
	}

	let accentColor = $state(loadSavedColor())
	let palette = $derived(generateSimplePalette(accentColor))

	// Apply colors whenever accent changes
	$effect(() => {
		applyDynamicColors({
			primary: palette.base,
			'primary-light': palette.light,
			'primary-dark': palette.dark,
			'primary-hover': palette.light,
			'primary-active': palette.dark,
			'primary-foreground': palette.foreground
		})

		// Persist to localStorage
		if (browser) {
			localStorage.setItem(storageKey, accentColor)
		}

		// Notify parent
		onchange?.(accentColor)
	})

	function handleColorInput(event: Event) {
		const input = event.target as HTMLInputElement
		accentColor = input.value
	}

	function resetToDefault() {
		accentColor = initialColor
		if (browser) {
			localStorage.removeItem(storageKey)
		}
	}

	// Preset colors for quick selection
	const presets = [
		{ name: 'Blue', value: '#3b82f6' },
		{ name: 'Purple', value: '#8b5cf6' },
		{ name: 'Pink', value: '#ec4899' },
		{ name: 'Red', value: '#ef4444' },
		{ name: 'Orange', value: '#f97316' },
		{ name: 'Green', value: '#22c55e' },
		{ name: 'Teal', value: '#14b8a6' }
	]
</script>

<div class="color-customizer">
	<div class="picker-section">
		<label class="color-label">
			<span>Accent Color</span>
			<input type="color" value={accentColor} oninput={handleColorInput} class="color-input" />
		</label>
		<code class="color-value">{accentColor}</code>
	</div>

	<div class="presets">
		<span class="presets-label">Quick picks:</span>
		{#each presets as preset (preset.value)}
			<button
				type="button"
				class="preset-btn"
				class:active={accentColor === preset.value}
				style:background-color={preset.value}
				style:color={getContrastColor(preset.value)}
				onclick={() => (accentColor = preset.value)}
				title={preset.name}
			>
				{#if accentColor === preset.value}{/if}
			</button>
		{/each}
	</div>

	<div class="palette-preview">
		<div class="swatch" style:background-color={palette.light}>Light</div>
		<div class="swatch" style:background-color={palette.base}>Base</div>
		<div class="swatch" style:background-color={palette.dark}>Dark</div>
	</div>

	<div class="preview-buttons">
		<button class="btn-primary">Primary Button</button>
		<button class="btn-primary-outline">Outline Button</button>
		<button type="button" class="btn-reset" onclick={resetToDefault}> Reset to Default </button>
	</div>
</div>

<style>
	.color-customizer {
		padding: 1.5rem;
		background: var(--color-surface);
		border: 1px solid var(--color-border);
		border-radius: 12px;
	}

	.picker-section {
		display: flex;
		align-items: center;
		gap: 1rem;
		margin-bottom: 1rem;
	}

	.color-label {
		display: flex;
		align-items: center;
		gap: 0.75rem;
		font-weight: 500;
	}

	.color-input {
		width: 48px;
		height: 48px;
		padding: 0;
		border: 2px solid var(--color-border);
		border-radius: 8px;
		cursor: pointer;
	}

	.color-input::-webkit-color-swatch-wrapper {
		padding: 2px;
	}

	.color-input::-webkit-color-swatch {
		border-radius: 4px;
		border: none;
	}

	.color-value {
		font-family: monospace;
		font-size: 0.875rem;
		color: var(--color-foreground-muted);
	}

	.presets {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		margin-bottom: 1.5rem;
	}

	.presets-label {
		font-size: 0.875rem;
		color: var(--color-foreground-muted);
	}

	.preset-btn {
		width: 32px;
		height: 32px;
		border: 2px solid transparent;
		border-radius: 6px;
		cursor: pointer;
		font-size: 0.875rem;
		transition:
			transform 0.15s,
			border-color 0.15s;
	}

	.preset-btn:hover {
		transform: scale(1.1);
	}

	.preset-btn.active {
		border-color: var(--color-foreground);
	}

	.palette-preview {
		display: flex;
		gap: 4px;
		margin-bottom: 1.5rem;
	}

	.swatch {
		flex: 1;
		padding: 1rem;
		text-align: center;
		font-size: 0.75rem;
		font-weight: 500;
		color: white;
		text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
		border-radius: 6px;
	}

	.preview-buttons {
		display: flex;
		gap: 0.75rem;
		flex-wrap: wrap;
	}

	.btn-primary {
		padding: 0.625rem 1.25rem;
		background-color: var(--color-primary);
		color: var(--color-primary-foreground);
		border: none;
		border-radius: 6px;
		font-weight: 500;
		cursor: pointer;
		transition: background-color 0.2s;
	}

	.btn-primary:hover {
		background-color: var(--color-primary-hover);
	}

	.btn-primary:active {
		background-color: var(--color-primary-active);
	}

	.btn-primary-outline {
		padding: 0.625rem 1.25rem;
		background: transparent;
		color: var(--color-primary);
		border: 2px solid var(--color-primary);
		border-radius: 6px;
		font-weight: 500;
		cursor: pointer;
		transition:
			background-color 0.2s,
			color 0.2s;
	}

	.btn-primary-outline:hover {
		background-color: var(--color-primary);
		color: var(--color-primary-foreground);
	}

	.btn-reset {
		padding: 0.625rem 1.25rem;
		background: var(--color-surface-hover);
		color: var(--color-foreground-muted);
		border: none;
		border-radius: 6px;
		font-size: 0.875rem;
		cursor: pointer;
	}
</style>

Using the Color Customizer

<!-- src/routes/settings/+page.svelte -->
<script>
	import ColorCustomizer from '$lib/components/ColorCustomizer.svelte'

	function handleColorChange(color: string) {
		// Optionally sync to server
		fetch('/api/user/preferences', {
			method: 'PATCH',
			body: JSON.stringify({ accentColor: color })
		})
	}
</script>

<h1>Appearance Settings</h1>

<section>
	<h2>Customize Your Theme</h2>
	<p>Choose an accent color that matches your style.</p>

	<ColorCustomizer
		initialColor="#3b82f6"
		storageKey="user-accent-color"
		onchange={handleColorChange}
	/>
</section>

Pattern 2: API-Driven Themes

When brand colors come from a backend API, CMS, or database, you need to fetch and apply them at runtime. This pattern is common for:

  • Multi-brand applications
  • CMS-managed marketing sites
  • Partner portal customization
  • Organization-specific branding
// src/lib/theme/brand-theme.svelte.ts
import { browser } from '$app/environment'
import { applyDynamicColors, clearDynamicColors } from './dynamic-colors.svelte.js'

export interface BrandColors {
	primary: string
	primaryHover: string
	secondary: string
	accent: string
	headerBg?: string
	headerFg?: string
}

export interface BrandTheme {
	id: string
	name: string
	colors: BrandColors
	logo?: string
}

// Module-level reactive state
let currentBrand = $state<BrandTheme | null>(null)
let isLoading = $state(false)
let error = $state<string | null>(null)

const BRAND_COLOR_KEYS = [
	'primary',
	'primary-hover',
	'secondary',
	'accent',
	'header-bg',
	'header-fg'
]

/**
 * Fetches brand theme from API and applies it.
 */
export async function loadBrandTheme(brandId: string): Promise<BrandTheme | null> {
	if (!browser) return null

	isLoading = true
	error = null

	try {
		const response = await fetch(`/api/brands/${brandId}/theme`)

		if (!response.ok) {
			throw new Error(`Failed to load brand theme: ${response.status}`)
		}

		const theme: BrandTheme = await response.json()
		applyBrandTheme(theme)

		// Cache for faster subsequent loads
		cacheBrandTheme(brandId, theme)

		return theme
	} catch (e) {
		error = e instanceof Error ? e.message : 'Failed to load brand theme'
		console.error('Brand theme error:', e)
		return null
	} finally {
		isLoading = false
	}
}

/**
 * Applies a brand theme to CSS custom properties.
 */
export function applyBrandTheme(theme: BrandTheme): void {
	currentBrand = theme

	const colors: Record<string, string> = {
		primary: theme.colors.primary,
		'primary-hover': theme.colors.primaryHover,
		secondary: theme.colors.secondary,
		accent: theme.colors.accent
	}

	// Optional header colors
	if (theme.colors.headerBg) {
		colors['header-bg'] = theme.colors.headerBg
	}
	if (theme.colors.headerFg) {
		colors['header-fg'] = theme.colors.headerFg
	}

	applyDynamicColors(colors)
}

/**
 * Loads cached theme immediately, then refreshes from API.
 * Prevents flash of default colors on repeat visits.
 */
export function loadBrandThemeWithCache(brandId: string): void {
	if (!browser) return

	// Apply cached version immediately
	const cached = getCachedBrandTheme(brandId)
	if (cached) {
		applyBrandTheme(cached)
	}

	// Refresh from API in background
	loadBrandTheme(brandId)
}

/**
 * Clears brand theme, reverting to CSS defaults.
 */
export function clearBrandTheme(): void {
	currentBrand = null
	clearDynamicColors(BRAND_COLOR_KEYS)
}

/**
 * Access current brand state reactively.
 */
export function useBrandTheme() {
	return {
		get brand() {
			return currentBrand
		},
		get isLoading() {
			return isLoading
		},
		get error() {
			return error
		},
		get hasBrand() {
			return currentBrand !== null
		},
		load: loadBrandTheme,
		loadWithCache: loadBrandThemeWithCache,
		apply: applyBrandTheme,
		clear: clearBrandTheme
	}
}

// Cache helpers
function cacheBrandTheme(brandId: string, theme: BrandTheme): void {
	try {
		localStorage.setItem(`brand-theme-${brandId}`, JSON.stringify(theme))
	} catch {
		// localStorage might be full or disabled
	}
}

function getCachedBrandTheme(brandId: string): BrandTheme | null {
	try {
		const cached = localStorage.getItem(`brand-theme-${brandId}`)
		return cached ? JSON.parse(cached) : null
	} catch {
		return null
	}
}

Using API-Driven Themes

<!-- src/routes/+layout.svelte -->
<script lang="ts">
	import { onMount } from 'svelte'
	import { useBrandTheme } from '$lib/theme/brand-theme.svelte.js'
	import ThemeProvider from '$lib/theme/ThemeProvider.svelte'

	let { children, data } = $props()

	const brandTheme = useBrandTheme()

	onMount(() => {
		// Load brand from route data or user's organization
		if (data.brandId) {
			brandTheme.loadWithCache(data.brandId)
		}
	})
</script>

<ThemeProvider>
	{#if brandTheme.isLoading && !brandTheme.brand}
		<div class="loading-brand">Loading brand theme...</div>
	{/if}

	{#if brandTheme.error}
		<div class="brand-error">
			<p>Failed to load brand theme. Using defaults.</p>
		</div>
	{/if}

	<header class="app-header">
		{#if brandTheme.brand?.logo}
			<img src={brandTheme.brand.logo} alt="{brandTheme.brand.name} logo" />
		{/if}
		<span>{brandTheme.brand?.name ?? 'Your App'}</span>
	</header>

	{@render children()}
</ThemeProvider>

<style>
	.app-header {
		display: flex;
		align-items: center;
		gap: 1rem;
		padding: 1rem;
		background-color: var(--color-header-bg, var(--color-primary));
		color: var(--color-header-fg, var(--color-primary-foreground));
	}

	.app-header img {
		height: 32px;
		width: auto;
	}
</style>

Pattern 3: Runtime Color Computation with Chroma.js

When you need sophisticated color manipulation—generating complete palettes, ensuring accessibility, or creating harmonious color schemes—pure JavaScript color math isn’t enough. Chroma.js provides professional-grade color science.

Setup

npm install chroma-js
npm install -D @types/chroma-js

Palette Generator

// src/lib/theme/color-palette.svelte.ts
import { browser } from '$app/environment'
import chroma from 'chroma-js'
import { applyDynamicColors } from './dynamic-colors.svelte.js'

export interface ColorScale {
	50: string
	100: string
	200: string
	300: string
	400: string
	500: string // base color
	600: string
	700: string
	800: string
	900: string
	950: string
}

/**
 * Generates a Tailwind-style color scale from a single base color.
 * Uses perceptually uniform LAB color space for smooth transitions.
 */
export function generateColorScale(baseColor: string): ColorScale {
	const base = chroma(baseColor)

	// Generate lighter shades (50-400)
	const lightScale = chroma.scale(['#ffffff', base]).mode('lab').colors(6)

	// Generate darker shades (600-950)
	const darkScale = chroma.scale([base, '#000000']).mode('lab').colors(6)

	return {
		50: lightScale[1],
		100: lightScale[2],
		200: lightScale[3],
		300: lightScale[4],
		400: lightScale[5],
		500: base.hex(),
		600: darkScale[1],
		700: darkScale[2],
		800: darkScale[3],
		900: darkScale[4],
		950: darkScale[5]
	}
}

/**
 * Determines the best text color for a background.
 * Uses WCAG contrast ratio guidelines.
 */
export function getAccessibleForeground(bgColor: string): string {
	const bg = chroma(bgColor)
	const white = chroma('#ffffff')
	const dark = chroma('#0f172a')

	// Calculate contrast ratios
	const whiteContrast = chroma.contrast(bg, white)
	const darkContrast = chroma.contrast(bg, dark)

	// WCAG AA requires 4.5:1 for normal text
	// Prefer the color with better contrast
	return whiteContrast >= darkContrast ? '#ffffff' : '#0f172a'
}

/**
 * Applies a complete color scale as CSS custom properties.
 */
export function applyColorScale(name: string, baseColor: string): void {
	if (!browser) return

	const scale = generateColorScale(baseColor)
	const foreground = getAccessibleForeground(baseColor)

	applyDynamicColors({
		[`${name}-50`]: scale[50],
		[`${name}-100`]: scale[100],
		[`${name}-200`]: scale[200],
		[`${name}-300`]: scale[300],
		[`${name}-400`]: scale[400],
		[`${name}-500`]: scale[500],
		[`${name}-600`]: scale[600],
		[`${name}-700`]: scale[700],
		[`${name}-800`]: scale[800],
		[`${name}-900`]: scale[900],
		[`${name}-950`]: scale[950],
		// Semantic aliases
		[name]: scale[500],
		[`${name}-hover`]: scale[600],
		[`${name}-active`]: scale[700],
		[`${name}-foreground`]: foreground
	})
}

/**
 * Generates a complementary color scheme from a primary color.
 */
export function applyComplementaryScheme(primaryColor: string): void {
	if (!browser) return

	const primary = chroma(primaryColor)

	// Complementary: opposite on color wheel (180°)
	const secondary = primary.set('hsl.h', '+180')

	// Accent: 90° offset for visual interest
	const accent = primary.set('hsl.h', '+90')

	applyColorScale('primary', primary.hex())
	applyColorScale('secondary', secondary.hex())
	applyColorScale('accent', accent.hex())
}

/**
 * Generates an analogous color scheme (colors next to each other).
 */
export function applyAnalogousScheme(primaryColor: string): void {
	if (!browser) return

	const primary = chroma(primaryColor)
	const secondary = primary.set('hsl.h', '+30')
	const accent = primary.set('hsl.h', '-30')

	applyColorScale('primary', primary.hex())
	applyColorScale('secondary', secondary.hex())
	applyColorScale('accent', accent.hex())
}

/**
 * Creates a triadic color scheme (three colors equally spaced).
 */
export function applyTriadicScheme(primaryColor: string): void {
	if (!browser) return

	const primary = chroma(primaryColor)
	const secondary = primary.set('hsl.h', '+120')
	const accent = primary.set('hsl.h', '+240')

	applyColorScale('primary', primary.hex())
	applyColorScale('secondary', secondary.hex())
	applyColorScale('accent', accent.hex())
}

Palette Generator Component

<!-- src/lib/components/PaletteGenerator.svelte -->
<script lang="ts">
	import {
		generateColorScale,
		applyColorScale,
		applyComplementaryScheme,
		applyAnalogousScheme,
		applyTriadicScheme,
		getAccessibleForeground
	} from '$lib/theme/color-palette.svelte.js'

	type SchemeType = 'single' | 'complementary' | 'analogous' | 'triadic'

	let baseColor = $state('#6366f1')
	let schemeType = $state<SchemeType>('single')
	let scale = $derived(generateColorScale(baseColor))

	// Apply scheme whenever inputs change
	$effect(() => {
		switch (schemeType) {
			case 'complementary':
				applyComplementaryScheme(baseColor)
				break
			case 'analogous':
				applyAnalogousScheme(baseColor)
				break
			case 'triadic':
				applyTriadicScheme(baseColor)
				break
			default:
				applyColorScale('primary', baseColor)
		}
	})

	const schemeOptions: { value: SchemeType; label: string; description: string }[] = [
		{ value: 'single', label: 'Single Color', description: 'One color with light/dark variations' },
		{
			value: 'complementary',
			label: 'Complementary',
			description: 'Opposite colors for high contrast'
		},
		{ value: 'analogous', label: 'Analogous', description: 'Adjacent colors for harmony' },
		{ value: 'triadic', label: 'Triadic', description: 'Three evenly spaced colors' }
	]
</script>

<div class="palette-generator">
	<div class="controls">
		<label class="color-control">
			<span>Base Color</span>
			<input type="color" bind:value={baseColor} />
			<code>{baseColor}</code>
		</label>

		<fieldset class="scheme-selector">
			<legend>Color Scheme</legend>
			{#each schemeOptions as option (option.value)}
				<label class="scheme-option">
					<input type="radio" name="scheme" value={option.value} bind:group={schemeType} />
					<span class="option-content">
						<strong>{option.label}</strong>
						<small>{option.description}</small>
					</span>
				</label>
			{/each}
		</fieldset>
	</div>

	<div class="scale-preview">
		<h3>Generated Scale</h3>
		<div class="scale-row">
			{#each Object.entries(scale) as [shade, color] (shade)}
				{@const fg = getAccessibleForeground(color)}
				<div class="scale-swatch" style:background-color={color} style:color={fg}>
					<span class="shade-label">{shade}</span>
					<span class="color-hex">{color}</span>
				</div>
			{/each}
		</div>
	</div>

	<div class="usage-preview">
		<h3>Live Preview</h3>
		<div class="preview-cards">
			<div class="preview-card primary">
				<h4>Primary</h4>
				<button class="preview-btn">Button</button>
			</div>
			{#if schemeType !== 'single'}
				<div class="preview-card secondary">
					<h4>Secondary</h4>
					<button class="preview-btn secondary-btn">Button</button>
				</div>
				<div class="preview-card accent">
					<h4>Accent</h4>
					<button class="preview-btn accent-btn">Button</button>
				</div>
			{/if}
		</div>
	</div>
</div>

<style>
	.palette-generator {
		padding: 1.5rem;
		background: var(--color-surface);
		border-radius: 12px;
	}

	.controls {
		display: flex;
		flex-wrap: wrap;
		gap: 2rem;
		margin-bottom: 2rem;
	}

	.color-control {
		display: flex;
		align-items: center;
		gap: 0.75rem;
	}

	.color-control input[type='color'] {
		width: 48px;
		height: 48px;
		border: none;
		border-radius: 8px;
		cursor: pointer;
	}

	.scheme-selector {
		border: 1px solid var(--color-border);
		border-radius: 8px;
		padding: 1rem;
	}

	.scheme-selector legend {
		font-weight: 600;
		padding: 0 0.5rem;
	}

	.scheme-option {
		display: flex;
		align-items: flex-start;
		gap: 0.5rem;
		margin-top: 0.5rem;
		cursor: pointer;
	}

	.option-content {
		display: flex;
		flex-direction: column;
	}

	.option-content small {
		color: var(--color-foreground-muted);
		font-size: 0.75rem;
	}

	.scale-preview h3,
	.usage-preview h3 {
		margin: 0 0 1rem;
		font-size: 1rem;
	}

	.scale-row {
		display: flex;
		border-radius: 8px;
		overflow: hidden;
	}

	.scale-swatch {
		flex: 1;
		padding: 1rem 0.25rem;
		text-align: center;
		min-width: 0;
	}

	.shade-label {
		display: block;
		font-weight: 600;
		font-size: 0.75rem;
	}

	.color-hex {
		display: block;
		font-size: 0.625rem;
		font-family: monospace;
		opacity: 0.8;
	}

	.usage-preview {
		margin-top: 2rem;
	}

	.preview-cards {
		display: flex;
		gap: 1rem;
		flex-wrap: wrap;
	}

	.preview-card {
		flex: 1;
		min-width: 150px;
		padding: 1rem;
		border-radius: 8px;
		background: var(--color-background);
		border: 1px solid var(--color-border);
	}

	.preview-card h4 {
		margin: 0 0 0.75rem;
		font-size: 0.875rem;
	}

	.preview-btn {
		width: 100%;
		padding: 0.5rem 1rem;
		border: none;
		border-radius: 6px;
		font-weight: 500;
		cursor: pointer;
		background: var(--color-primary);
		color: var(--color-primary-foreground);
	}

	.preview-btn.secondary-btn {
		background: var(--color-secondary);
		color: var(--color-secondary-foreground);
	}

	.preview-btn.accent-btn {
		background: var(--color-accent);
		color: var(--color-accent-foreground);
	}
</style>

Pattern 4: Third-Party Library Integration

Libraries like Chart.js, D3, and Canvas APIs need color values in JavaScript—they can’t read CSS variables directly. This pattern bridges the gap.

Theme Color Reader

// src/lib/theme/chart-theme.svelte.ts
import { browser } from '$app/environment'

/**
 * Reads computed CSS custom property values for use in JS libraries.
 */
export function getThemeColors(): Record<string, string> {
	// Return sensible defaults for SSR
	if (!browser) {
		return {
			background: '#ffffff',
			surface: '#f8fafc',
			foreground: '#0f172a',
			foregroundMuted: '#64748b',
			primary: '#3b82f6',
			secondary: '#8b5cf6',
			success: '#22c55e',
			warning: '#eab308',
			error: '#ef4444',
			border: '#e2e8f0'
		}
	}

	const styles = getComputedStyle(document.documentElement)

	const getColor = (name: string) => styles.getPropertyValue(`--color-${name}`).trim() || undefined

	return {
		background: getColor('background') ?? '#ffffff',
		surface: getColor('surface') ?? '#f8fafc',
		foreground: getColor('foreground') ?? '#0f172a',
		foregroundMuted: getColor('foreground-muted') ?? '#64748b',
		primary: getColor('primary') ?? '#3b82f6',
		secondary: getColor('secondary') ?? '#8b5cf6',
		success: getColor('success') ?? '#22c55e',
		warning: getColor('warning') ?? '#eab308',
		error: getColor('error') ?? '#ef4444',
		border: getColor('border') ?? '#e2e8f0'
	}
}

/**
 * Converts hex color to rgba with opacity.
 * Useful for semi-transparent fills in charts.
 */
export function withOpacity(hexColor: string, opacity: number): string {
	const hex = hexColor.replace('#', '')
	const r = parseInt(hex.substring(0, 2), 16)
	const g = parseInt(hex.substring(2, 4), 16)
	const b = parseInt(hex.substring(4, 6), 16)
	return `rgba(${r}, ${g}, ${b}, ${opacity})`
}

/**
 * Generates an array of colors for multi-series charts.
 */
export function getChartColorPalette(count: number): string[] {
	const colors = getThemeColors()
	const palette = [colors.primary, colors.secondary, colors.success, colors.warning, colors.error]

	// Cycle through palette if more colors needed
	return Array.from({ length: count }, (_, i) => palette[i % palette.length])
}

Theme-Aware Chart Component

<!-- src/lib/components/ThemeAwareChart.svelte -->
<script lang="ts">
	import { onMount } from 'svelte'
	import { Chart, registerables } from 'chart.js'
	import { getThemeContext } from '$lib/theme/theme-context.svelte.js'
	import { getThemeColors, withOpacity } from '$lib/theme/chart-theme.svelte.js'

	Chart.register(...registerables)

	interface DataPoint {
		label: string
		value: number
	}

	interface Props {
		data: DataPoint[]
		title?: string
		type?: 'line' | 'bar' | 'doughnut'
	}

	let { data, title, type = 'line' }: Props = $props()

	const theme = getThemeContext()
	let canvas = $state<HTMLCanvasElement | null>(null)
	let chartInstance: Chart | null = null

	// Recreate chart when theme or data changes
	$effect(() => {
		// Access theme.mode to create dependency
		const currentMode = theme.mode
		const currentData = data

		if (!canvas) return

		// Destroy existing chart
		if (chartInstance) {
			chartInstance.destroy()
			chartInstance = null
		}

		// Get fresh colors after theme change
		const colors = getThemeColors()

		const chartConfig = {
			type,
			data: {
				labels: currentData.map((d) => d.label),
				datasets: [
					{
						label: title ?? 'Data',
						data: currentData.map((d) => d.value),
						borderColor: colors.primary,
						backgroundColor: type === 'line' ? withOpacity(colors.primary, 0.1) : colors.primary,
						borderWidth: type === 'line' ? 2 : 0,
						fill: type === 'line',
						tension: 0.4
					}
				]
			},
			options: {
				responsive: true,
				maintainAspectRatio: false,
				plugins: {
					legend: {
						display: !!title,
						labels: {
							color: colors.foreground,
							font: { family: 'system-ui' }
						}
					},
					tooltip: {
						backgroundColor: colors.surface,
						titleColor: colors.foreground,
						bodyColor: colors.foregroundMuted,
						borderColor: colors.border,
						borderWidth: 1
					}
				},
				scales:
					type !== 'doughnut'
						? {
								x: {
									ticks: { color: colors.foregroundMuted },
									grid: { color: withOpacity(colors.border, 0.5) }
								},
								y: {
									ticks: { color: colors.foregroundMuted },
									grid: { color: withOpacity(colors.border, 0.5) }
								}
							}
						: undefined
			}
		}

		chartInstance = new Chart(canvas, chartConfig)

		return () => {
			if (chartInstance) {
				chartInstance.destroy()
				chartInstance = null
			}
		}
	})
</script>

<div class="chart-wrapper">
	<canvas bind:this={canvas}></canvas>
</div>

<style>
	.chart-wrapper {
		position: relative;
		height: 300px;
		padding: 1rem;
		background: var(--color-surface);
		border: 1px solid var(--color-border);
		border-radius: 12px;
	}
</style>

Usage Example

<!-- src/routes/dashboard/+page.svelte -->
<script>
	import ThemeAwareChart from '$lib/components/ThemeAwareChart.svelte'

	const salesData = [
		{ label: 'Jan', value: 120 },
		{ label: 'Feb', value: 150 },
		{ label: 'Mar', value: 180 },
		{ label: 'Apr', value: 220 },
		{ label: 'May', value: 280 },
		{ label: 'Jun', value: 340 }
	]
</script>

<h1>Dashboard</h1>

<section class="charts-grid">
	<ThemeAwareChart data={salesData} title="Monthly Sales" type="line" />

	<ThemeAwareChart data={salesData} title="Sales by Month" type="bar" />
</section>

<style>
	.charts-grid {
		display: grid;
		grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
		gap: 1.5rem;
	}
</style>

Pattern 5: White-Label SaaS Multi-Tenant Theming

For SaaS applications where each customer has their own branding, you need a complete tenant theming system. This pattern handles subdomain detection, branding API integration, and SSR considerations.

Tenant Theme System

// src/lib/theme/tenant-theme.svelte.ts
import { browser } from '$app/environment'
import { applyDynamicColors, clearDynamicColors } from './dynamic-colors.svelte.js'

export interface TenantBranding {
	tenantId: string
	name: string
	domain: string
	logo: string
	favicon?: string
	colors: {
		primary: string
		primaryHover: string
		secondary: string
		accent: string
		headerBg: string
		headerFg: string
	}
	fonts?: {
		heading?: string
		body?: string
	}
}

// Module-level state
let currentTenant = $state<TenantBranding | null>(null)
let isLoading = $state(false)
let error = $state<string | null>(null)

const TENANT_COLOR_KEYS = [
	'primary',
	'primary-hover',
	'secondary',
	'accent',
	'header-bg',
	'header-fg'
]

/**
 * Detects tenant from current subdomain.
 * Returns null if on main domain or localhost.
 */
export function detectTenantSlug(): string | null {
	if (!browser) return null

	const hostname = window.location.hostname

	// Skip localhost
	if (hostname === 'localhost' || hostname === '127.0.0.1') {
		return null
	}

	const parts = hostname.split('.')

	// Need at least subdomain.domain.tld
	if (parts.length < 3) return null

	const subdomain = parts[0]

	// Skip common non-tenant subdomains
	if (['www', 'app', 'api', 'admin'].includes(subdomain)) {
		return null
	}

	return subdomain
}

/**
 * Loads tenant branding from API.
 */
export async function loadTenantBranding(tenantSlug: string): Promise<TenantBranding | null> {
	isLoading = true
	error = null

	try {
		const response = await fetch(`/api/tenants/${tenantSlug}/branding`)

		if (!response.ok) {
			if (response.status === 404) {
				throw new Error('Tenant not found')
			}
			throw new Error('Failed to load tenant branding')
		}

		const branding: TenantBranding = await response.json()
		applyTenantBranding(branding)

		// Cache for faster subsequent loads
		cacheTenantBranding(tenantSlug, branding)

		return branding
	} catch (e) {
		error = e instanceof Error ? e.message : 'Unknown error'
		console.error('Tenant branding error:', e)
		return null
	} finally {
		isLoading = false
	}
}

/**
 * Applies tenant branding to the application.
 */
export function applyTenantBranding(branding: TenantBranding): void {
	if (!browser) return

	currentTenant = branding

	// Apply colors
	applyDynamicColors({
		primary: branding.colors.primary,
		'primary-hover': branding.colors.primaryHover,
		secondary: branding.colors.secondary,
		accent: branding.colors.accent,
		'header-bg': branding.colors.headerBg,
		'header-fg': branding.colors.headerFg
	})

	// Apply custom fonts
	const root = document.documentElement
	if (branding.fonts?.heading) {
		root.style.setProperty('--font-heading', branding.fonts.heading)
	}
	if (branding.fonts?.body) {
		root.style.setProperty('--font-body', branding.fonts.body)
	}

	// Update page title
	document.title = `${branding.name}`

	// Update favicon
	if (branding.favicon) {
		updateFavicon(branding.favicon)
	}
}

/**
 * Initializes tenant from subdomain automatically.
 */
export async function initTenantFromSubdomain(): Promise<TenantBranding | null> {
	const slug = detectTenantSlug()
	if (!slug) return null

	// Try cache first for instant load
	const cached = getCachedTenantBranding(slug)
	if (cached) {
		applyTenantBranding(cached)
	}

	// Load fresh from API
	return loadTenantBranding(slug)
}

/**
 * Clears tenant branding, reverting to defaults.
 */
export function clearTenantBranding(): void {
	if (!browser) return

	currentTenant = null
	clearDynamicColors(TENANT_COLOR_KEYS)

	const root = document.documentElement
	root.style.removeProperty('--font-heading')
	root.style.removeProperty('--font-body')
}

/**
 * Reactive access to tenant state.
 */
export function useTenant() {
	return {
		get tenant() {
			return currentTenant
		},
		get isLoading() {
			return isLoading
		},
		get error() {
			return error
		},
		get isWhiteLabel() {
			return currentTenant !== null
		},
		get name() {
			return currentTenant?.name ?? 'App'
		},
		get logo() {
			return currentTenant?.logo ?? '/logo.svg'
		},
		init: initTenantFromSubdomain,
		load: loadTenantBranding,
		apply: applyTenantBranding,
		clear: clearTenantBranding
	}
}

// Helper functions
function updateFavicon(url: string): void {
	let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
	if (!link) {
		link = document.createElement('link')
		link.rel = 'icon'
		document.head.appendChild(link)
	}
	link.href = url
}

function cacheTenantBranding(slug: string, branding: TenantBranding): void {
	try {
		localStorage.setItem(`tenant-${slug}`, JSON.stringify(branding))
	} catch {
		/* ignore */
	}
}

function getCachedTenantBranding(slug: string): TenantBranding | null {
	try {
		const cached = localStorage.getItem(`tenant-${slug}`)
		return cached ? JSON.parse(cached) : null
	} catch {
		return null
	}
}

SSR Support for Tenant Theming

To prevent flash of default colors, inject tenant styles server-side:

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit'

interface TenantColors {
	primary: string
	headerBg: string
	headerFg: string
}

// In production, this would fetch from your database
async function getTenantColors(slug: string): Promise<TenantColors | null> {
	// Example: fetch from database or cache
	const response = await fetch(`${process.env.API_URL}/tenants/${slug}/colors`)
	if (!response.ok) return null
	return response.json()
}

export const handle: Handle = async ({ event, resolve }) => {
	// Extract tenant from subdomain
	const host = event.request.headers.get('host') ?? ''
	const parts = host.split('.')

	// Check if this is a tenant subdomain
	if (parts.length >= 3 && !['www', 'app', 'api'].includes(parts[0])) {
		const tenantSlug = parts[0]
		const colors = await getTenantColors(tenantSlug)

		if (colors) {
			return resolve(event, {
				transformPageChunk: ({ html }) => {
					// Inject tenant colors as inline styles
					const tenantStyles = `
						<style id="tenant-theme">
							:root {
								--color-primary: ${colors.primary};
								--color-header-bg: ${colors.headerBg};
								--color-header-fg: ${colors.headerFg};
							}
						</style>
					`
					return html.replace('</head>', `${tenantStyles}</head>`)
				}
			})
		}
	}

	return resolve(event)
}

Multi-Tenant Layout

<!-- src/routes/+layout.svelte -->
<script lang="ts">
	import { onMount } from 'svelte'
	import { useTenant } from '$lib/theme/tenant-theme.svelte.js'
	import ThemeProvider from '$lib/theme/ThemeProvider.svelte'

	let { children } = $props()

	const tenant = useTenant()

	onMount(() => {
		tenant.init()
	})
</script>

<ThemeProvider>
	<div class="app-layout">
		<header class="app-header">
			<a href="/" class="brand">
				<img src={tenant.logo} alt="{tenant.name} logo" class="brand-logo" />
				<span class="brand-name">{tenant.name}</span>
			</a>

			<nav class="header-nav">
				<slot name="nav" />
			</nav>
		</header>

		<main class="app-main">
			{@render children()}
		</main>

		<footer class="app-footer">
			<p>© {new Date().getFullYear()} {tenant.name}. All rights reserved.</p>
		</footer>
	</div>
</ThemeProvider>

<style>
	.app-layout {
		min-height: 100vh;
		display: flex;
		flex-direction: column;
	}

	.app-header {
		display: flex;
		align-items: center;
		justify-content: space-between;
		padding: 0.75rem 1.5rem;
		background-color: var(--color-header-bg, var(--color-primary));
		color: var(--color-header-fg, var(--color-primary-foreground));
	}

	.brand {
		display: flex;
		align-items: center;
		gap: 0.75rem;
		text-decoration: none;
		color: inherit;
	}

	.brand-logo {
		height: 36px;
		width: auto;
	}

	.brand-name {
		font-family: var(--font-heading, system-ui);
		font-weight: 600;
		font-size: 1.125rem;
	}

	.app-main {
		flex: 1;
		padding: 2rem;
	}

	.app-footer {
		padding: 1.5rem;
		text-align: center;
		background: var(--color-surface);
		border-top: 1px solid var(--color-border);
		color: var(--color-foreground-muted);
		font-size: 0.875rem;
	}
</style>

Common Mistakes

Using JavaScript CSS properties for static themes

// Don't do this for light/dark switching
applyDynamicColors({
	background: isDark ? '#0f172a' : '#ffffff'
	// ... more colors
})

FIX: Use CSS selectors instead

:root[data-theme='dark'] {
	--color-background: #0f172a;
}

Forgetting SSR fallbacks

// This crashes on server
const styles = getComputedStyle(document.documentElement)

FIX: Always check for browser

if (!browser) return defaultColors
const styles = getComputedStyle(document.documentElement)

Not cleaning up dynamic colors

// Colors persist even after component unmounts
applyDynamicColors({ primary: userColor })

FIX: Clear colors when appropriate

onDestroy(() => {
	clearDynamicColors(['primary'])
})

Performance Considerations

Dynamic CSS properties have performance implications:

  1. Batch updates — Use a single applyDynamicColors() call instead of multiple setProperty() calls. Each property change can trigger style recalculation.

  2. Avoid in animations — Don’t update CSS custom properties in animation loops. Use CSS transitions or Web Animations API instead.

  3. Cache computed colors — If reading colors frequently (e.g., for canvas), cache the values rather than calling getComputedStyle() repeatedly.

  4. SSR critical path — For white-label apps, inject critical tenant colors server-side to avoid layout shift.


Key Takeaways

  1. CSS-first is still the default — Only use JavaScript CSS properties when colors are genuinely unknown at build time.

  2. Five legitimate use cases — User customization, API-driven themes, runtime computation, third-party integration, and white-label SaaS.

  3. Always handle SSR — Check for browser before accessing document, and provide sensible defaults.

  4. Cache aggressively — Store dynamic colors in localStorage to prevent flash on repeat visits.

  5. Clean up properly — Remove dynamic properties when they’re no longer needed.

  6. Batch your updates — Minimize style recalculations by updating multiple properties at once.


Further Reading

External Resources