DEV Community

Ethan Walker
Ethan Walker

Posted on

Advanced Theme Customization and Dynamic Color Schemes with m3-svelte in Svelte

m3-svelte is a powerful Material Design 3 component library for Svelte, providing a complete set of UI components with modern theming support. This article covers advanced theme customization, creating dynamic color schemes, and implementing light/dark theme switching with programmatic control. This is part 15 of a series on using m3-svelte with Svelte.

This guide walks through creating a dynamic theming system with m3-svelte in Svelte, from basic setup to implementing complex scenarios with theme switching, custom color palettes, and user preference adaptation.

Prerequisites

Before starting, ensure you have:

  • A SvelteKit project (SvelteKit 1.0+ or standalone Svelte 4+)
  • Node.js 18+ and npm/pnpm/yarn
  • Basic understanding of CSS custom properties (CSS variables)
  • Knowledge of color spaces (RGB, HSL)
  • Understanding of Svelte reactivity and stores

Action example: CSS custom properties in m3-svelte work through the token system --m3-scheme-[color]. When you set --m3-scheme-primary: 103 80 164, the library automatically applies this color to all components using the primary color. This allows you to change the entire application's color scheme by modifying just a few variables in :root.

Installation

Install m3-svelte and necessary dependencies:

npm install m3-svelte
Enter fullscreen mode Exit fullscreen mode

For working with themes, you may also need a color utility:

npm install color
Enter fullscreen mode Exit fullscreen mode

Verify installation:

npm list m3-svelte
Enter fullscreen mode Exit fullscreen mode

Project Setup

Create the project structure for working with themes:

mkdir -p src/lib/themes
mkdir -p src/lib/stores
Enter fullscreen mode Exit fullscreen mode

Copy the base m3-svelte theme. Go to the official m3-svelte website and copy the CSS theme code into src/app.css:

/* src/app.css */
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap');

/* Paste the CSS theme code from m3-svelte website here */

:root {
  /* Custom styles will go here */
}

body {
  display: flex;
  height: 100dvh;
  margin: 0;
  box-sizing: border-box;
  background: rgb(var(--m3-scheme-background));
  color: rgb(var(--m3-scheme-on-background));
  font-family: 'Roboto', sans-serif;
  transition: background-color 0.3s ease, color 0.3s ease;
}
Enter fullscreen mode Exit fullscreen mode

Link the styles in src/app.html (for SvelteKit) or in the main component:

<!-- src/app.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="/app.css" />
  </head>
  <body>
    <div id="app">%sveltekit.body%</div>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

First Example / Basic Usage

Create a simple component to demonstrate basic theming:

<!-- src/routes/+page.svelte -->
<script>
  import Button from 'm3-svelte/Button.svelte';
  import Card from 'm3-svelte/Card.svelte';
</script>

<Card>
  <h2>Basic m3-svelte Theming</h2>
  <p>This example demonstrates using the standard Material Design 3 theme.</p>
  <Button>Primary Button</Button>
</Card>

<style>
  :global(body) {
    padding: 2rem;
  }

  h2 {
    margin: 0 0 1rem 0;
    color: rgb(var(--m3-scheme-on-surface));
  }

  p {
    margin: 0 0 1.5rem 0;
    color: rgb(var(--m3-scheme-on-surface-variant));
  }
</style>
Enter fullscreen mode Exit fullscreen mode

This example shows basic usage of m3-svelte components with the standard theme. All colors are automatically applied through CSS custom properties.

Understanding the Basics

m3-svelte uses a CSS custom properties system for theme management. Main variable groups:

  1. Color tokens (--m3-scheme-[color]): Define interface colors
  2. Elevation (--m3-util-elevation-[0-5]): Control shadows and depth
  3. Rounding (--m3-util-rounding-[size]): Control border radius

Example of changing the primary color:

:root {
  --m3-scheme-primary: 103 80 164; /* RGB values without rgb() */
  --m3-scheme-on-primary: 255 255 255;
  --m3-scheme-primary-container: 234 221 255;
  --m3-scheme-on-primary-container: 33 0 94;
}
Enter fullscreen mode Exit fullscreen mode

All components automatically use these values. Changing variables instantly updates the entire interface.

Practical Example / Building Something Real

Let's create a complete dynamic theming system with switching between light and dark themes, plus custom color palettes.

First, create a store for theme management:

// src/lib/stores/theme.ts
import { writable } from 'svelte/store';
import { browser } from '$app/environment';

export type ThemeMode = 'light' | 'dark' | 'auto';
export type ColorScheme = 'default' | 'ocean' | 'forest' | 'sunset';

interface ThemeConfig {
  mode: ThemeMode;
  scheme: ColorScheme;
}

const defaultTheme: ThemeConfig = {
  mode: 'auto',
  scheme: 'default'
};

function createThemeStore() {
  const { subscribe, set, update } = writable<ThemeConfig>(defaultTheme);

  // Load saved theme from localStorage
  if (browser) {
    const saved = localStorage.getItem('m3-theme');
    if (saved) {
      try {
        set(JSON.parse(saved));
      } catch {
        set(defaultTheme);
      }
    }
  }

  return {
    subscribe,
    setMode: (mode: ThemeMode) => {
      update((config) => {
        const newConfig = { ...config, mode };
        if (browser) {
          localStorage.setItem('m3-theme', JSON.stringify(newConfig));
          applyTheme(newConfig);
        }
        return newConfig;
      });
    },
    setScheme: (scheme: ColorScheme) => {
      update((config) => {
        const newConfig = { ...config, scheme };
        if (browser) {
          localStorage.setItem('m3-theme', JSON.stringify(newConfig));
          applyTheme(newConfig);
        }
        return newConfig;
      });
    },
    set: (config: ThemeConfig) => {
      if (browser) {
        localStorage.setItem('m3-theme', JSON.stringify(config));
        applyTheme(config);
      }
      set(config);
    }
  };
}

function applyTheme(config: ThemeConfig) {
  if (!browser) return;

  const root = document.documentElement;
  const isDark = config.mode === 'dark' || 
    (config.mode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);

  // Determine color scheme
  const colorPalette = getColorPalette(config.scheme, isDark);

  // Apply colors to CSS variables
  Object.entries(colorPalette).forEach(([key, value]) => {
    root.style.setProperty(`--m3-scheme-${key}`, value);
  });

  // Set data attribute for additional styles
  root.setAttribute('data-theme', isDark ? 'dark' : 'light');
}

function getColorPalette(scheme: ColorScheme, isDark: boolean): Record<string, string> {
  const palettes = {
    default: {
      light: {
        primary: '103 80 164',
        'on-primary': '255 255 255',
        'primary-container': '234 221 255',
        'on-primary-container': '33 0 94',
        secondary: '98 91 113',
        'on-secondary': '255 255 255',
        'secondary-container': '232 222 248',
        'on-secondary-container': '30 25 43',
        tertiary: '125 82 96',
        'on-tertiary': '255 255 255',
        'tertiary-container': '255 216 228',
        'on-tertiary-container': '55 11 30',
        error: '186 26 26',
        'on-error': '255 255 255',
        'error-container': '255 218 214',
        'on-error-container': '65 0 2',
        background: '255 251 254',
        'on-background': '28 27 31',
        surface: '255 251 254',
        'on-surface': '28 27 31',
        'surface-variant': '231 224 236',
        'on-surface-variant': '73 69 79',
        outline: '121 116 126',
        'outline-variant': '202 196 208'
      },
      dark: {
        primary: '208 188 255',
        'on-primary': '55 30 115',
        'primary-container': '79 55 139',
        'on-primary-container': '234 221 255',
        secondary: '204 194 220',
        'on-secondary': '51 45 65',
        'secondary-container': '74 68 88',
        'on-secondary-container': '232 222 248',
        tertiary: '239 184 200',
        'on-tertiary': '75 37 52',
        'tertiary-container': '99 59 72',
        'on-tertiary-container': '255 216 228',
        error: '255 180 171',
        'on-error': '105 0 5',
        'error-container': '147 0 10',
        'on-error-container': '255 218 214',
        background: '28 27 31',
        'on-background': '230 225 229',
        surface: '28 27 31',
        'on-surface': '230 225 229',
        'surface-variant': '73 69 79',
        'on-surface-variant': '202 196 208',
        outline: '147 143 153',
        'outline-variant': '73 69 79'
      }
    },
    ocean: {
      light: {
        primary: '0 103 192',
        'on-primary': '255 255 255',
        'primary-container': '207 227 255',
        'on-primary-container': '0 31 65',
        secondary: '83 94 111',
        'on-secondary': '255 255 255',
        'secondary-container': '215 227 247',
        'on-secondary-container': '16 28 43',
        tertiary: '0 121 107',
        'on-tertiary': '255 255 255',
        'tertiary-container': '118 255 243',
        'on-tertiary-container': '0 54 48',
        error: '186 26 26',
        'on-error': '255 255 255',
        'error-container': '255 218 214',
        'on-error-container': '65 0 2',
        background: '249 250 253',
        'on-background': '25 28 32',
        surface: '249 250 253',
        'on-surface': '25 28 32',
        'surface-variant': '224 227 235',
        'on-surface-variant': '68 71 79',
        outline: '118 121 129',
        'outline-variant': '196 199 207'
      },
      dark: {
        primary: '159 202 255',
        'on-primary': '0 51 83',
        'primary-container': '0 79 135',
        'on-primary-container': '207 227 255',
        secondary: '187 199 219',
        'on-secondary': '38 49 65',
        'secondary-container': '61 72 88',
        'on-secondary-container': '215 227 247',
        tertiary: '85 216 198',
        'on-tertiary': '0 73 66',
        'tertiary-container': '0 95 85',
        'on-tertiary-container': '118 255 243',
        error: '255 180 171',
        'on-error': '105 0 5',
        'error-container': '147 0 10',
        'on-error-container': '255 218 214',
        background: '18 20 24',
        'on-background': '225 227 231',
        surface: '18 20 24',
        'on-surface': '225 227 231',
        'surface-variant': '68 71 79',
        'on-surface-variant': '196 199 207',
        outline: '144 148 156',
        'outline-variant': '68 71 79'
      }
    },
    forest: {
      light: {
        primary: '16 109 32',
        'on-primary': '255 255 255',
        'primary-container': '142 248 139',
        'on-primary-container': '0 37 0',
        secondary: '82 99 79',
        'on-secondary': '255 255 255',
        'secondary-container': '213 240 205',
        'on-secondary-container': '16 31 15',
        tertiary: '56 101 29',
        'on-tertiary': '255 255 255',
        'tertiary-container': '183 236 130',
        'on-tertiary-container': '13 33 0',
        error: '186 26 26',
        'on-error': '255 255 255',
        'error-container': '255 218 214',
        'on-error-container': '65 0 2',
        background: '252 253 247',
        'on-background': '25 28 23',
        surface: '252 253 247',
        'on-surface': '25 28 23',
        'surface-variant': '222 228 218',
        'on-surface-variant': '61 68 57',
        outline: '113 119 108',
        'outline-variant': '194 200 189'
      },
      dark: {
        primary: '116 219 110',
        'on-primary': '0 57 0',
        'primary-container': '0 82 0',
        'on-primary-container': '142 248 139',
        secondary: '185 213 177',
        'on-secondary': '37 54 35',
        'secondary-container': '59 76 57',
        'on-secondary-container': '213 240 205',
        tertiary: '156 209 105',
        'on-tertiary': '26 52 0',
        'tertiary-container': '40 75 0',
        'on-tertiary-container': '183 236 130',
        error: '255 180 171',
        'on-error': '105 0 5',
        'error-container': '147 0 10',
        'on-error-container': '255 218 214',
        background: '18 20 15',
        'on-background': '225 227 220',
        surface: '18 20 15',
        'on-surface': '225 227 220',
        'surface-variant': '61 68 57',
        'on-surface-variant': '194 200 189',
        outline: '141 147 136',
        'outline-variant': '61 68 57'
      }
    },
    sunset: {
      light: {
        primary: '191 69 0',
        'on-primary': '255 255 255',
        'primary-container': '255 219 190',
        'on-primary-container': '62 18 0',
        secondary: '137 82 78',
        'on-secondary': '255 255 255',
        'secondary-container': '255 218 214',
        'on-secondary-container': '59 18 16',
        tertiary: '178 94 0',
        'on-tertiary': '255 255 255',
        'tertiary-container': '255 220 183',
        'on-tertiary-container': '58 25 0',
        error: '186 26 26',
        'on-error': '255 255 255',
        'error-container': '255 218 214',
        'on-error-container': '65 0 2',
        background: '255 251 248',
        'on-background': '32 26 23',
        surface: '255 251 248',
        'on-surface': '32 26 23',
        'surface-variant': '245 223 210',
        'on-surface-variant': '80 68 60',
        outline: '152 132 119',
        'outline-variant': '230 207 193'
      },
      dark: {
        primary: '255 183 134',
        'on-primary': '102 32 0',
        'primary-container': '145 48 0',
        'on-primary-container': '255 219 190',
        secondary: '230 184 180',
        'on-secondary': '92 50 47',
        'secondary-container': '115 64 60',
        'on-secondary-container': '255 218 214',
        tertiary: '255 186 112',
        'on-tertiary': '94 48 0',
        'tertiary-container': '135 71 0',
        'on-tertiary-container': '255 220 183',
        error: '255 180 171',
        'on-error': '105 0 5',
        'error-container': '147 0 10',
        'on-error-container': '255 218 214',
        background: '23 18 15',
        'on-background': '237 225 218',
        surface: '23 18 15',
        'on-surface': '237 225 218',
        'surface-variant': '80 68 60',
        'on-surface-variant': '230 207 193',
        outline: '178 158 145',
        'outline-variant': '80 68 60'
      }
    }
  };

  return palettes[scheme][isDark ? 'dark' : 'light'];
}

export const themeStore = createThemeStore();

// Initialize theme on load
if (browser) {
  themeStore.subscribe((config) => {
    applyTheme(config);
  });

  // Listen for system theme changes
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
    themeStore.subscribe((config) => {
      if (config.mode === 'auto') {
        applyTheme(config);
      }
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Now create a component for theme control:

<!-- src/lib/components/ThemeController.svelte -->
<script lang="ts">
  import { themeStore, type ThemeMode, type ColorScheme } from '$lib/stores/theme';
  import Card from 'm3-svelte/Card.svelte';
  import { Arrows } from 'm3-svelte';

  let mode: ThemeMode = 'auto';
  let scheme: ColorScheme = 'default';

  $: {
    themeStore.subscribe((config) => {
      mode = config.mode;
      scheme = config.scheme;
    });
  }

  function handleModeChange(newMode: ThemeMode) {
    themeStore.setMode(newMode);
  }

  function handleSchemeChange(newScheme: ColorScheme) {
    themeStore.setScheme(newScheme);
  }
</script>

<Card>
  <h3>Theme Control</h3>

  <div class="control-group">
    <label>
      <span>Theme Mode:</span>
      <Arrows
        list={['light', 'dark', 'auto']}
        bind:value={mode}
        onchange={() => handleModeChange(mode)}
      />
      <span class="value">{mode === 'light' ? 'Light' : mode === 'dark' ? 'Dark' : 'Auto'}</span>
    </label>
  </div>

  <div class="control-group">
    <label>
      <span>Color Scheme:</span>
      <Arrows
        list={['default', 'ocean', 'forest', 'sunset']}
        bind:value={scheme}
        onchange={() => handleSchemeChange(scheme)}
      />
      <span class="value">
        {scheme === 'default' ? 'Default' :
         scheme === 'ocean' ? 'Ocean' :
         scheme === 'forest' ? 'Forest' :
         'Sunset'}
      </span>
    </label>
  </div>
</Card>

<style>
  .control-group {
    margin-bottom: 1.5rem;
  }

  .control-group:last-child {
    margin-bottom: 0;
  }

  label {
    display: flex;
    align-items: center;
    gap: 1rem;
    cursor: pointer;
  }

  label span:first-child {
    min-width: 120px;
    color: rgb(var(--m3-scheme-on-surface));
  }

  .value {
    color: rgb(var(--m3-scheme-primary));
    font-weight: 500;
  }

  h3 {
    margin: 0 0 1.5rem 0;
    color: rgb(var(--m3-scheme-on-surface));
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Create a demonstration page:

<!-- src/routes/+page.svelte -->
<script>
  import Button from 'm3-svelte/Button.svelte';
  import Card from 'm3-svelte/Card.svelte';
  import FAB from 'm3-svelte/FAB.svelte';
  import { ThemeController } from '$lib/components/ThemeController.svelte';
  import { themeStore } from '$lib/stores/theme';
</script>

<div class="container">
  <ThemeController />

  <Card>
    <h1>Dynamic Theming Demonstration</h1>
    <p>
      Change the theme mode or color scheme above to see how all components 
      automatically update.
    </p>

    <div class="button-group">
      <Button>Primary Button</Button>
      <Button variant="outlined">Outlined Button</Button>
      <Button variant="text">Text Button</Button>
    </div>

    <div class="fab-container">
      <FAB icon="add" />
    </div>
  </Card>

  <Card>
    <h2>Current Theme Settings</h2>
    <pre>{JSON.stringify($themeStore, null, 2)}</pre>
  </Card>
</div>

<style>
  .container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 2rem;
    display: flex;
    flex-direction: column;
    gap: 2rem;
  }

  h1 {
    margin: 0 0 1rem 0;
    color: rgb(var(--m3-scheme-on-surface));
  }

  h2 {
    margin: 0 0 1rem 0;
    color: rgb(var(--m3-scheme-on-surface));
  }

  p {
    margin: 0 0 2rem 0;
    color: rgb(var(--m3-scheme-on-surface-variant));
    line-height: 1.6;
  }

  .button-group {
    display: flex;
    gap: 1rem;
    flex-wrap: wrap;
    margin-bottom: 2rem;
  }

  .fab-container {
    display: flex;
    justify-content: center;
    padding: 2rem;
  }

  pre {
    background: rgb(var(--m3-scheme-surface-variant));
    color: rgb(var(--m3-scheme-on-surface-variant));
    padding: 1rem;
    border-radius: 8px;
    overflow-x: auto;
    margin: 0;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Add theme initialization to the layout:

<!-- src/routes/+layout.svelte -->
<script>
  import { onMount } from 'svelte';
  import { themeStore } from '$lib/stores/theme';
  import '../app.css';

  onMount(() => {
    // Apply saved theme on load
    themeStore.subscribe(() => {});
  });
</script>

<slot />
Enter fullscreen mode Exit fullscreen mode

Common Issues / Troubleshooting

  1. Theme not applying on first load

    • Ensure CSS variables are set before component rendering
    • Use onMount for theme initialization in layout
  2. Colors displaying incorrectly

    • Check RGB value format: they should be without rgb() (e.g., 103 80 164, not rgb(103, 80, 164))
    • Ensure all necessary CSS variables are defined
  3. Theme switching is slow

    • Add CSS transitions for smooth animation: transition: background-color 0.3s ease, color 0.3s ease;
    • Use requestAnimationFrame for update synchronization
  4. Theme not persisting between sessions

    • Check that localStorage is available (browser only)
    • Use browser check from $app/environment before accessing localStorage

Next Steps

Now that you've mastered advanced theming, you can:

  • Learn to create custom color palettes based on user colors
  • Integrate theming with operating system settings
  • Create a visual theme editor for users
  • Study performance optimization when switching themes
  • Read other articles in the series about working with specific m3-svelte components

Useful resources:

Summary

You've learned to create a dynamic theming system with m3-svelte, including switching between light and dark themes, multiple color schemes, and saving user preferences. You can now apply this knowledge to create adaptive and personalized interfaces that automatically adjust to user preferences.

Top comments (0)