m3-svelte Theming: The Complete Guide to Dynamic Material Design 3 Color Schemes
There’s a particular kind of satisfaction in watching a UI shift seamlessly from light to dark — not because of a hard page reload, but because you built it right. If you’re working with
m3-svelte
and Material Design 3,
that satisfaction is well within reach. The library brings Google’s latest design system into the Svelte ecosystem — and when you pair it with reactive stores, CSS custom properties, and a bit of localStorage discipline, you get a theming architecture that’s both elegant and genuinely resilient.
This guide covers everything from the foundational color token model of Material You to production-ready patterns for advanced theme customization and dynamic color schemes with m3-svelte. Whether you’re building a SvelteKit app from scratch or retrofitting an existing project, you’ll leave with a working mental model — and working code.
Why Material Design 3 Theming Is Different This Time
Material Design 3 — also called Material You — isn’t just a visual refresh. It introduces a systematic, algorithmic approach to color: you define a single source color, and the system derives an entire palette of harmonious tones using the HCT (Hue, Chroma, Tone) color space. That palette then maps to semantic roles: primary, secondary, tertiary, surface, error, and their respective container and on-color variants. The result is a color token system that’s both mathematically consistent and deeply flexible.
What makes this relevant to Svelte developers is that m3-svelte exposes all of these tokens as CSS custom properties — specifically scoped under the --md-sys-color-* namespace. This means that switching between a light and dark theme, or between entirely different color schemes, doesn’t require re-rendering components. It requires changing property values. That’s not just a performance win; it’s a conceptual win for how you think about interface personalization in Svelte.
The practical implication: your entire component tree can be theme-aware without a single component knowing what the current theme is. The cascade does the work. This is the kind of architecture that scales from a prototype to a production app without rewrites, which is precisely why understanding it deeply is worth your time.
Setting Up m3-svelte with Custom Color Schemes
Getting started with Material Design 3 color customization in Svelte requires two things: the m3-svelte package and a generated theme. You install the library via npm (npm install m3-svelte), and then you generate your theme — either using the official
Material Theme Builder
or programmatically via @material/material-color-utilities. The output is a set of CSS custom property declarations for both light and dark schemes.
A typical theme file looks like a pair of CSS rule blocks — one for :root or a .light class, one for .dark — each containing 30–40 color property assignments. Drop that into your global stylesheet or app.css, apply the appropriate class to your <body> or a root wrapper, and every m3-svelte component immediately responds to the correct palette. No props drilling. No context juggling. Just the cascade.
If you want to go further — generating themes dynamically from a user-provided source color at runtime — you’ll reach for @material/material-color-utilities directly in the browser. The library’s themeFromSourceColor function accepts a hex color and returns the full M3 scheme. You then write those values back to the DOM using document.documentElement.style.setProperty(). It sounds heavier than it is; the actual computation is fast enough to run on color picker input events without throttling.
Reactive Theming with Svelte Stores
The cleanest architecture for reactive theming in Svelte centers on a writable store. You create a themeStore that holds the current theme mode — 'light' or 'dark' — and subscribe to it wherever you need to reflect that state in the DOM. The subscription updates a class on document.body, which activates the correct set of CSS custom properties. Everything downstream reacts automatically.
// src/lib/stores/theme.ts
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
type ThemeMode = 'light' | 'dark';
const stored = browser
? (localStorage.getItem('theme') as ThemeMode) ?? 'light'
: 'light';
export const themeMode = writable<ThemeMode>(stored);
if (browser) {
themeMode.subscribe((value) => {
document.body.classList.remove('light', 'dark');
document.body.classList.add(value);
localStorage.setItem('theme', value);
});
}
This pattern handles both reactivity and persistence in one place. The Svelte stores theming approach keeps your theme logic centralized — one source of truth — while the localStorage write on every subscription update ensures that the user’s preference survives page reloads. The browser guard from SvelteKit prevents SSR errors when the store initializes on the server where neither localStorage nor document exist.
For more complex scenarios — say, a user-defined custom color layered on top of a light/dark toggle — you can extend the store to hold a full theme object: { mode: 'dark', sourceColor: '#6750A4' }. The subscription then handles both the class switch and the dynamic property injection. This gives you programmatic theme control without coupling your UI components to any specific theming logic.
Implementing Light and Dark Theme Switching
A light and dark theme toggle in Svelte is straightforward once your store and CSS are in place. The component itself needs almost no logic — it reads from the store, dispatches an update, and lets the subscription chain do the rest. A minimal toggle looks like a button that calls a single function:
<!-- src/lib/components/ThemeToggle.svelte -->
<script lang="ts">
import { themeMode } from '$lib/stores/theme';
function toggle() {
themeMode.update((current) => (current === 'light' ? 'dark' : 'light'));
}
</script>
<button on:click={toggle} aria-label="Toggle theme">
{$themeMode === 'light' ? '🌙 Dark' : '☀️ Light'}
</button>
The component is intentionally thin. Business logic lives in the store; presentation logic lives in the component. This separation means you can replace the button with an icon, a switch, or a dropdown for multiple color schemes without touching the underlying theming infrastructure. It also makes testing trivial — you test the store in isolation and the component’s rendering separately.
One detail worth getting right: the system preference. Users who’ve set their OS to dark mode expect apps to respect that by default. You handle this by initializing the store not just from localStorage but from window.matchMedia('(prefers-color-scheme: dark)') as a fallback. If localStorage has a value, use it; if not, defer to the system. Once the user makes an explicit choice, their preference overrides the system default. This is the behavior users expect even if they never articulate it.
CSS Custom Properties as the Theming Engine
CSS custom properties theming is the mechanism that makes all of this work at zero runtime cost for component rendering. When you write color: var(--md-sys-color-primary) in a stylesheet, the browser resolves that at paint time — and when you change the property’s value on an ancestor element, all descendants repaint automatically. No JavaScript re-renders. No virtual DOM diffing. Just the browser doing what it’s designed to do.
The M3 color token set is deliberately semantic, not descriptive. You never use --md-sys-color-blue-500; you use --md-sys-color-primary. This means the same component works correctly in a purple theme and a green theme without modification. Your button’s primary color follows the theme; your chip’s surface color follows the theme; your dialog’s background follows the theme. The entire component library is theme-native by default, which is a significant advantage over systems where you manually pass color values as props.
A useful pattern for adaptive theming is to define your custom overrides in layers: the m3-svelte generated theme sets the base tokens, and you layer component-specific or section-specific overrides on top using more tightly scoped CSS rules. A sidebar might use a slightly more saturated surface; a modal might darken its backdrop token. Because everything flows through custom properties, you get this granular control without touching JavaScript at all — it’s pure CSS composition.
Dynamic Color Generation at Runtime
Static themes cover most use cases. But if your product’s value proposition includes genuine interface personalization in Svelte — a user-defined accent color, a brand-matched palette for white-label deployments, or a ”color from wallpaper” feature — you need runtime color generation. This is where @material/material-color-utilities becomes indispensable.
The workflow: the user selects a source color via a color picker (or your app derives one from their profile image, or from a system API). You pass that color’s ARGB integer to themeFromSourceColor(), receive a complete M3 scheme, and iterate over its schemes.light and schemes.dark properties to inject CSS custom properties into the document root. The function is synchronous and fast — you can run it on every input event of a color picker and the UI updates in real time without perceptible lag.
// src/lib/utils/generateTheme.ts
import {
themeFromSourceColor,
argbFromHex,
applyTheme
} from '@material/material-color-utilities';
export function applySourceColor(hex: string, isDark: boolean) {
const theme = themeFromSourceColor(argbFromHex(hex));
applyTheme(theme, { target: document.body, dark: isDark });
}
Note the applyTheme utility from the same package — it handles the property injection loop for you, targeting any DOM element you specify. Combine this with your theme store, and you have a fully reactive, fully dynamic color pipeline. Store the source color in local theme storage alongside the mode, and the user’s custom palette persists across sessions. This is the same architecture that powers Android’s Material You wallpaper-based theming — you’re just running it in a browser.
Persisting Theme Preferences: Local Storage and Beyond
Local theme storage in Svelte via localStorage is the baseline. It’s synchronous, universally available in browsers, and zero-dependency. For most applications, it’s enough. The pattern is simple: write on every theme change, read on initialization. The critical edge case — flash of wrong theme on load — is solved by running the initialization script as early as possible, ideally inline in app.html before any JavaScript bundles parse.
<!-- app.html (SvelteKit) -->
<script>
// Runs synchronously before page renders — prevents theme flash
(function () {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored ?? (prefersDark ? 'dark' : 'light');
document.body.classList.add(theme);
})();
</script>
For applications with user accounts, you’ll want to sync theme preferences to a backend so they persist across devices and browsers. The store pattern accommodates this cleanly: add a server sync call to your subscription alongside the localStorage write. On login, fetch the stored preference and update the store. The UI responds immediately. The user never sees the wrong theme.
A word on cookies: they’re sometimes recommended over localStorage for SSR environments because they’re available on the server during the initial request, enabling server-side theme rendering. In SvelteKit, you can read a theme cookie in a layout’s load function and pass it as a prop, setting the class server-side. This eliminates the flash entirely without the inline script workaround. It’s more complexity, but for applications where first-paint fidelity is critical — say, a public-facing product with premium positioning — it’s the right call.
Extending m3-svelte: Multiple Color Schemes and Theme Variants
Color schemes in m3-svelte don’t have to be binary. Material Design 3 defines four official dynamic color schemes — Tonal Spot, Vibrant, Expressive, and Neutral — each producing a distinct aesthetic from the same source color. You can expose these as user-selectable options, or use them as the basis for semantic variations: a calmer scheme for a reading interface, a more vibrant one for an entertainment context.
The implementation mirrors the single-scheme approach: generate all four scheme variants from the source color at initialization, cache them, and swap CSS custom properties when the user selects a different variant. Because the properties are all in the same namespace, the swap is instantaneous. You can combine this with the light/dark toggle for a matrix of 8 distinct visual states — all controlled by two store values.
Beyond the official schemes, m3-svelte allows component-level overrides through props and CSS. If a specific component in your app needs a color treatment that diverges from the theme — a destructive action button in a deeper red, a featured card with a custom tonal surface — you can scope a CSS override to that component without polluting the global token space. The key discipline is to override at the token level, not the computed value level. Change --md-sys-color-primary on a scoped element rather than directly setting color: red. That way, your override plays correctly with both light and dark modes.
Performance, Accessibility, and Production Considerations
Adaptive theming done well is invisible to users — which means the performance overhead needs to be invisible too. CSS custom property switches are GPU-friendly; the browser doesn’t recalculate layout, only applies visual changes. Runtime color generation with themeFromSourceColor is fast for one-off calls but should be debounced if triggered by continuous input. A 150ms debounce on a color picker’s input event feels instantaneous to users while cutting computation by an order of magnitude.
Accessibility is non-negotiable. M3’s color system is designed with contrast ratios in mind — the default token pairings (e.g., --md-sys-color-on-primary on --md-sys-color-primary) meet WCAG AA at the system-defined tone values. When you customize, especially when letting users choose source colors, you’re potentially introducing low-contrast combinations. Consider running a contrast check (the Contrast utility in @material/material-color-utilities handles this) before applying user-selected colors, and warn or correct when the ratio falls below 4.5:1.
In production, don’t overlook the interaction between your theming system and third-party components or embeds. Anything that injects its own styles — maps, widgets, chat tools — won’t pick up your CSS custom properties. Plan for this explicitly: either scope your theme to a container (rather than body) that excludes third-party content, or accept that those elements will be visually inconsistent and compensate with a wrapper overlay. It’s a minor issue but one that bites developers who discover it during QA rather than during architecture.
Frequently Asked Questions
How do I implement light/dark mode switching in m3-svelte?
Create a Svelte writable store that holds 'light' or 'dark' as its value. Subscribe to the store and update document.body.classList on every change, toggling between the two theme classes. Your m3-svelte CSS — which scopes all color tokens to .light and .dark selectors — handles the rest automatically. Bind a toggle button to the store with a simple themeMode.update() call.
How do I persist a user’s theme choice in Svelte?
Write the current theme value to localStorage inside your store’s subscription, and read it back during store initialization using localStorage.getItem('theme'). To prevent a flash of the wrong theme on page load, inject a small synchronous script into app.html (before the SvelteKit bootstrap) that reads localStorage and applies the correct class to document.body immediately. Fall back to prefers-color-scheme for first-time visitors.
How do CSS custom properties power theming in Material Design 3 Svelte?
Material Design 3 maps its entire color system to CSS custom properties in the --md-sys-color-* namespace. m3-svelte components consume these properties internally, so switching themes requires only changing the property values on a root element — no component re-rendering, no prop drilling. Define light and dark theme blocks in your global CSS, swap the active class on body, and every component in your app responds instantly through the browser’s native cascade.
References:
Advanced Theme Customization with m3-svelte (dev.to) ·
Material Design 3 Color System ·
Svelte Stores Documentation