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.
PrerequisitesThis 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.
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 Type | Why Users Need Custom Colors |
|---|---|
| Personal dashboards | Match workspace to preferences, reduce eye strain |
| Note-taking apps | Color-code notebooks, folders, tags for organization |
| Social profiles | Express personality, match personal brand |
| Accessibility tools | Custom high-contrast combinations for vision needs |
| Design tools | Preview work in different color contexts |
| Gaming platforms | Team 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:
- Make HTTP requests to fetch data
- Read from databases
- Parse JSON responses
- 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:
- Developer updates CSS file
- Code review and approval
- CI/CD pipeline runs
- Deployment to production
- 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:
| Scenario | Data Source | Why It Can’t Be Static |
|---|---|---|
| CMS-managed marketing sites | Contentful, Sanity, Strapi | Content editors control branding |
| Multi-brand applications | Brand database | One codebase, many brands (Coca-Cola family) |
| Partner portals | Partner API | Each reseller has custom branding |
| A/B testing | Experiment service | Different users see different colors |
| Seasonal themes | Campaign database | Holiday themes without deployment |
| Enterprise customization | Customer settings | Each enterprise client has brand colors |
The Data Flow:
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:
- Convert color spaces — Transforming hex to HSL to LAB requires mathematical functions CSS doesn’t have
- Perform iterative calculations — Generating 11 shades (50-950) requires loops
- Calculate contrast ratios — WCAG compliance requires luminance calculations
- 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:
| Application | Color Math Required |
|---|---|
| Design system generators | Full palette from brand color |
| Data visualization | Gradient scales for heatmaps, choropleth maps |
| Accessibility checkers | WCAG contrast ratio calculations |
| Theme builders | User picks one color, app generates cohesive scheme |
| Progress indicators | Color transitions based on percentage (red → yellow → green) |
| Sentiment analysis UIs | Color 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:
| Library | Why CSS Variables Don’t Work |
|---|---|
| Chart.js | Passes colors directly to Canvas 2D context |
| D3.js | Sets SVG attributes as strings, not CSS |
| Three.js | WebGL requires numeric color values (0x3b82f6) |
| Mapbox/Leaflet | GeoJSON styling uses JavaScript objects |
| GSAP/Anime.js | Interpolates between color values in JS |
| jsPDF | PDF specification requires explicit colors |
| Fabric.js | Canvas manipulation library |
| PixiJS | WebGL-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:
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:
| Problem | Impact |
|---|---|
| CSS bundle size | Grows linearly with tenants. 1000 tenants = massive file |
| Deployment coupling | New tenant or color change = code deployment |
| Self-service impossible | Tenants can’t customize their own branding |
| Build time | Slows as tenant count grows |
| Cache invalidation | Any tenant change invalidates everyone’s cache |
The Business Reality:
SaaS products often have these requirements:
- Tenant self-service: Admins customize branding without contacting support
- Instant updates: Changes visible immediately, not after deployment
- Unlimited scale: Support thousands of tenants without code changes
- Complete isolation: One tenant’s branding never leaks to another
- Beyond colors: Custom logos, favicons, fonts, and page titles
Real-World Applications:
| Product Type | Why Multi-Tenant Theming | Examples |
|---|---|---|
| B2B SaaS | Each company sees their brand | Slack, Notion, Linear |
| E-commerce platforms | Each merchant has branded store | Shopify, BigCommerce |
| LMS platforms | Schools see institutional branding | Canvas, Moodle |
| Healthcare portals | Clinics have branded patient portals | Epic MyChart |
| Agency tools | White-label for agency clients | Many marketing tools |
| Franchise software | Each location has local branding | POS systems |
The Architecture:
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:
| Question | If Yes → | If No → |
|---|---|---|
| Are all colors defined in your CSS files? | CSS-first | Need JavaScript |
| Can marketing change colors without deployment? | Need JavaScript | CSS-first is fine |
| Do users pick their own colors? | Need JavaScript | CSS-first is fine |
| Do you generate palettes from one base color? | Need JavaScript | CSS-first is fine |
| Do Chart.js/D3/Canvas need your theme colors? | Need JavaScript | CSS-first is fine |
| Does each customer see different branding? | Need JavaScript | CSS-first is fine |
Rule of ThumbIf 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:
Batch updates — Use a single
applyDynamicColors()call instead of multiplesetProperty()calls. Each property change can trigger style recalculation.Avoid in animations — Don’t update CSS custom properties in animation loops. Use CSS transitions or Web Animations API instead.
Cache computed colors — If reading colors frequently (e.g., for canvas), cache the values rather than calling
getComputedStyle()repeatedly.SSR critical path — For white-label apps, inject critical tenant colors server-side to avoid layout shift.
Key Takeaways
CSS-first is still the default — Only use JavaScript CSS properties when colors are genuinely unknown at build time.
Five legitimate use cases — User customization, API-driven themes, runtime computation, third-party integration, and white-label SaaS.
Always handle SSR — Check for
browserbefore accessingdocument, and provide sensible defaults.Cache aggressively — Store dynamic colors in localStorage to prevent flash on repeat visits.
Clean up properly — Remove dynamic properties when they’re no longer needed.
Batch your updates — Minimize style recalculations by updating multiple properties at once.
Further Reading
Related Articles
- Building Production-Ready Theme Systems with Context — The CSS-first foundation this article builds on
- Simple Theme Switching with useTheme Hook — Simpler approach for basic theming
External Resources
- Chroma.js Documentation — Professional color manipulation library
- Chart.js Theming — Official Chart.js color documentation
- WCAG Contrast Guidelines — Accessibility requirements for color contrast