Web Designguide12 min read

Material Design 3 Dynamic Color on the Web

Seed color to tonal palettes in HCT—not Android-only. Ship Material You themes with CSS variables.

The marketing site shipped with a single brand blue. The Android app picked up the user’s wallpaper and generated a coherent purple theme overnight. Same company, same quarter, two different color systems—and the web team was told to match Material You without a spec, a library, or a clear answer to what happens when the seed color is not the brand hex. The request sounded like a visual parity exercise. It was actually an algorithm problem dressed in brand language.

Material Design 3 Dynamic Color is not an Android skin. It is a computation: take a seed color, derive tonal palettes in the HCT color space (Hue, Chroma, Tone), and map those tones onto semantic roles—primary, secondary, tertiary, surface, error, and the container and on-container pairs that keep text readable. Google documents the system at the Material 3 color overview. The reference implementation for developers is @material/material-color-utilities, a TypeScript library extracted from the same code that powers Android 12+ theming. The web does not get wallpaper APIs. It does get the math, and the math is portable.

This article walks through HCT, how seed colors become palettes, how to run the utilities in a browser or build step, and how to emit CSS custom properties your components can consume. The goal is not to replicate every Android flourish. The goal is to adopt a tonal system that stays coherent when the seed moves, whether that seed is fixed brand hex, a user-selected accent, or a dominant color sampled from an uploaded image with permission and fallback.

The gap between Android and the web

Material You, the user-facing name for dynamic color in Material Design 3, replaces the old primary-secondary-accent triad with a tonal system. Instead of brand blue at 500, 600, and 700, you get palettes indexed by tone—discrete lightness steps from 0 near black to 100 near white, with hue and chroma held in a perceptually meaningful relationship. The engine works in HCT. Hue is the color angle on the wheel, roughly analogous to HSL hue but stabilized for palette generation. Chroma is colorfulness; HCT caps chroma based on what displays can reproduce and what keeps pairs accessible. Tone is perceived lightness on a 0–100 scale. Tone is the axis Material uses for surfaces, containers, and text-on-surface relationships—not raw HSL lightness, which lies across hues the way OKLCH fixes for palettes.

Google chose HCT because it was designed alongside dynamic color to keep contrast relationships predictable when the seed changes. A seed pulled from a sunset photo and a seed set to corporate navy should both produce on-primary text that reads on primary containers without hand-tuning twelve hex values. The Material 3 color overview describes the role mapping: primary, onPrimary, primaryContainer, onPrimaryContainer, parallel sets for secondary and tertiary, plus surface, surfaceVariant, outline, error, and the inverse roles used on image overlays and snackbars. Dynamic color fills those roles from generated palettes. Static brands can still use the same role names with fixed tones.

On Android, the seed often comes from the wallpaper via system APIs. On the web, you choose the seed explicitly: brand hex from the style guide, user-selected accent in a settings panel, or dominant color extracted from an uploaded avatar or hero image. The pipeline is the same in all three cases. Seed color flows through HCT analysis, tonal palettes emerge, semantic theme roles receive tone assignments, and CSS custom properties carry the result to components. What the web cannot copy from Android is system-wide harmonization, wallpaper auto-extraction without building it yourself, or per-app dynamic color on iOS. The valuable portable piece is the HCT palette math, not the wallpaper pipeline.

From seed color to CSS variables

The practical starting point is @material/material-color-utilities. Install it in your project, import themeFromSourceColor, argbFromHex, and hexFromArgb, and treat ARGB as an implementation detail you convert to hex for CSS. The package exports scheme variants for light, dark, and high-contrast modes. Your job is to decide when generation runs—client runtime for dashboards and settings-heavy apps, build time for marketing sites and SSR frameworks that cannot flash default purple before JavaScript hydrates.

A minimal browser-side implementation takes a seed hex, calls themeFromSourceColor, selects theme.schemes.light or .dark, and writes custom properties on document.documentElement. CamelCase role names from the library become kebab-case custom properties. Components reference --md-primary, not the seed hex, so when the seed changes every button using background: var(--md-primary) updates together. Marketing sites often prefer generating CSS at build time: a Node script reads BRAND_SEED from the environment, emits :root and [data-theme="dark"] blocks, and writes material-theme.css that you import once. Hydration never flashes the default M3 seed because the correct values already exist in the first paint.

import {
  themeFromSourceColor,
  argbFromHex,
  hexFromArgb,
} from '@material/material-color-utilities';

function applyMaterialTheme(seedHex) {
  const theme = themeFromSourceColor(argbFromHex(seedHex));
  const scheme = theme.schemes.light;
  const root = document.documentElement;

  const roles = [
    'primary', 'onPrimary', 'primaryContainer', 'onPrimaryContainer',
    'secondary', 'onSecondary', 'secondaryContainer', 'onSecondaryContainer',
    'tertiary', 'onTertiary', 'tertiaryContainer', 'onTertiaryContainer',
    'error', 'onError', 'errorContainer', 'onErrorContainer',
    'background', 'onBackground', 'surface', 'onSurface',
    'surfaceVariant', 'onSurfaceVariant', 'outline', 'outlineVariant',
    'shadow', 'scrim', 'inverseSurface', 'inverseOnSurface', 'inversePrimary',
  ];

  for (const role of roles) {
    const argb = scheme[role];
    const cssName = '--md-' + role.replace(/([A-Z])/g, '-$1').toLowerCase();
    root.style.setProperty(cssName, hexFromArgb(argb));
  }
}

applyMaterialTheme('#6750A4');

Build-time generation follows the same logic but writes static CSS. A script maps both light and dark schemes from one seed, filters numeric role values, converts each to hex, and joins selectors into a file your bundler imports. CI runs the script when the brand seed changes. Client runtime and build-time output should use identical custom property names so components never care which path populated them.

import { writeFileSync } from 'node:fs';
import { themeFromSourceColor, argbFromHex, hexFromArgb } from '@material/material-color-utilities';

const seed = process.env.BRAND_SEED ?? '#1E3A5F';
const light = themeFromSourceColor(argbFromHex(seed)).schemes.light;
const dark = themeFromSourceColor(argbFromHex(seed)).schemes.dark;

function schemeToCss(scheme, selector) {
  const lines = Object.entries(scheme)
    .filter(([, v]) => typeof v === 'number')
    .map(([role, argb]) => {
      const name = '--md-' + role.replace(/([A-Z])/g, '-$1').toLowerCase();
      return `  ${name}: ${hexFromArgb(argb)};`;
    });
  return `${selector} {\n${lines.join('\n')}\n}`;
}

const css = [
  schemeToCss(light, ':root'),
  schemeToCss(dark, '[data-theme="dark"]'),
].join('\n\n');

writeFileSync('src/styles/material-theme.css', css);

Mapping Material roles to your token system

Material role names are more granular than many existing design systems. You do not have to expose all twenty-eight variables on day one. A pragmatic mapping bridges Material output to tokens your team already documents. Primary becomes action color. OnPrimary becomes text on action. Surface becomes your base surface token. Outline becomes default border. Error becomes danger. The bridge lives in one file so the rest of the codebase keeps semantic names.

Material role Typical web token
primary –color-action-primary
onPrimary –color-text-on-primary
primaryContainer –color-surface-accent-subtle
onPrimaryContainer –color-text-on-accent-subtle
surface –surface-0
onSurface –text-primary
surfaceVariant –surface-1
outline –border-default
error –color-danger
:root {
  --color-action-primary: var(--md-primary);
  --color-text-on-primary: var(--md-on-primary);
  --surface-0: var(--md-surface);
  --text-primary: var(--md-on-surface);
  --border-default: var(--md-outline);
}

Elevation in Material 3 dark mode uses surface containers—surfaceContainerLow, surfaceContainer, surfaceContainerHigh—rather than shadows alone. If you generate only the base scheme object, add container tones from the library’s surface palette helpers or map surfaceVariant steps manually. When you export tokens for cross-tool consumption, align naming with the DTCG 2025.10 stable specification published October 2025: semantic groups, explicit light and dark values, and documented references between generated Material roles and your canonical token names. The spec does not replace HCT math. It gives design ops a portable format so Figma variables, Style Dictionary output, and generated material-theme.css stay traceable through release.

User-controlled dynamic color on the web means you build the seed picker. Practical patterns include a settings accent color stored in localStorage with applyMaterialTheme on load and debounced on change, offering curated seeds plus a native color input for power users. Image-derived seeds draw the upload to a canvas, sample a downscaled version, average or take dominant hue, validate contrast on onPrimary before applying, and fall back to brand seed if extraction fails minimum contrast. For prefers-color-scheme, generate both theme.schemes.light and theme.schemes.dark from the same seed. HCT keeps hue family consistent across modes; tone steps invert appropriately. Toggle with [data-theme="dark"] on html, not by running the algorithm twice with different seeds. A single themeFromSourceColor call can populate both selectors: light roles on :root and dark roles on [data-theme="dark"] from identical hue DNA.

Dynamic color is not a contrast guarantee. The Material algorithms aim for readable on-container pairs, but extreme seeds—neon yellow, near-white, near-black—can still produce edge cases. Treat generated themes like any other token set. Run text pairs through a contrast checker at AA minimum for body and UI. Test focus rings against both surface and primaryContainer backgrounds. Ship a fixed fallback theme when generation fails validation. High-contrast scheme variants ship in the library as lightHighContrast and darkHighContrast. Wire them to prefers-contrast: more when your audience includes low-vision users on Windows or macOS increased-contrast modes.

function safeApplyTheme(seedHex) {
  applyMaterialTheme(seedHex);
  const primary = getComputedStyle(document.documentElement)
    .getPropertyValue('--md-primary').trim();
  const onPrimary = getComputedStyle(document.documentElement)
    .getPropertyValue('--md-on-primary').trim();
  if (!meetsContrast(onPrimary, primary, 4.5)) {
    applyMaterialTheme('#6750A4');
  }
}

A case study from seed to ship

A fintech dashboard team inherited a web app themed with hand-picked hex values and an Android companion that had shipped Material You six months earlier. Product asked for visual alignment before a conference demo. The web lead assumed the task was copying purple from a screenshot. Two days in, every attempt to eyeball hex values from the Android build produced buttons that looked right in isolation and wrong beside real cards, snackbars, and error states. The Android team had not exported a palette. They had exported a feeling.

The breakthrough came when someone ran @material/material-color-utilities against the actual brand seed—the same navy the style guide listed for print—and compared the generated role map to a screenshot of the Android settings screen. Primary container on Android matched tone 90 on the generated primary palette, not the hex the web team had been using for subtle highlights. On-surface text on Android was not the gray from the old web neutral scale. It was onSurface from the scheme, and it shifted when the seed changed in the Android personalization lab. The web app had been mixing a fixed neutral ramp with a dynamic primary, which is why the products looked like cousins rather than siblings.

The team chose a hybrid architecture. Marketing pages and the logged-out experience received build-time generated CSS from a fixed brand seed. No runtime JavaScript, no flash, no support burden from user-chosen neon accents on public pages. The authenticated dashboard received runtime generation tied to a settings panel where users could pick from twelve curated seeds or use a native color input. Both paths wrote the same --md-* custom properties. A bridge file mapped those to the existing --surface-* and --text-* tokens so component refactors stayed minimal. They added safeApplyTheme validation after a tester chose a near-white seed and watched primary buttons disappear.

Dark mode had been a separate problem for years: someone had picked a different seed for dark because light mode navy felt too heavy on charcoal backgrounds. Material’s single-seed dual-scheme approach fixed that drift in one afternoon. One seed, theme.schemes.light and theme.schemes.dark, toggle via data-theme. Hue family stayed consistent. Tone inverted. The designer’s comment in the pull request was that the app finally felt like one system.

Accessibility review caught two edge cases before launch. An extracted seed from a profile photo produced acceptable onPrimary on the primary fill but a focus ring that failed adjacent contrast on primaryContainer cards. They added --focus-ring-on-primary and --focus-ring-on-container tokens derived from the scheme, not from a global ring color. A second failure came from a user seed so desaturated that chroma collapsed and every surface looked like warm gray concrete. That is algorithm behavior for earthy seeds, not a bug. They documented it in settings copy and clamped minimum chroma in post-processing for seeds below a threshold.

The conference demo went smoothly. More importantly, rebrand planning changed. Instead of a spreadsheet of twelve hex updates across forty components, the team changed BRAND_SEED, regenerated material-theme.css, and ran the bridge. Android and web still do not share wallpaper extraction. They share HCT output when given the same seed, which was the actual request underneath match Material You. Static brand sites can adopt the same structure without dynamic seeds: design defines one seed in Figma’s Material Theme Builder plugin, the build step emits CSS once per release, and rebrand changes the seed constant without renaming --md-on-surface across the codebase. That trade—perceptually even palettes and documented container pairs without runtime complexity—is often right for marketing even when dashboards embrace user tinting.

Debugging remains part of the job. Everything turning gray-purple usually means the default M3 seed never got replaced because client JavaScript did not run or the build script failed silently. Dark mode hue drifting from light mode means two different seeds per mode. Colors looking flat on wide-gamut displays means generated hex is still sRGB; key roles may need color(display-p3 r g b) with sRGB fallbacks. Chroma collapsing on muted seeds means the algorithm optimized for UI surfaces, not poster saturation. Material Design 3 dynamic color on the web reduces to three decisions: where the seed comes from, when you run the utilities, and how you map Material roles to your token names. HCT is the foundation. The Android wallpaper demo is marketing for the math. The math runs fine in npm, outputs CSS variables, and belongs in any web stack that already themes with custom properties.