A theme from one color
Pick a hex, get back thirty colors. That's the promise of Material 3, and the part that makes it polarising among Android developers. Some love the speed; others miss the control of hand-picking every color in a brand guideline. Whichever camp you're in, the algorithm doing the work is genuinely interesting, and worth knowing if you ever need to debug why your "perfect" seed color produced a theme that looks slightly off.
The trick is a color space Google built specifically for this called HCT (Hue, Chroma, Tone). The point of HCT is that Tone is calibrated to how light a color looks, not how light it measures. Two colors at tone 50 will appear roughly equally bright to the eye even if one is bright cyan and the other is dark red. RGB doesn't do that. HSL tries but isn't great at it. HCT, more or less, does. That's why generated M3 themes feel balanced where hand-rolled "I'll just darken the brand color by 30%" themes often don't.
When you give the algorithm a seed, it builds five tonal palettes (Primary, Secondary, Tertiary, Neutral, Neutral Variant), each containing 13 specific tones from 0 (pure black) to 100 (pure white) at irregular intervals: 0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99, 100. The Primary palette keeps your seed's hue. Secondary and Tertiary shift the hue by amounts that produce visual variety without clashing. The two Neutral palettes are low-chroma, close to gray, but with just a hint of your seed's hue so the surface colors feel like they belong to the same theme rather than being random gray.
Your primary color isn't a tonal palette itself. It's a specific point
on one. In the light theme it's the Primary palette at tone 40 (medium-dark), and
onPrimary (text that sits on top of it) is tone 100 (white).
primaryContainer is tone 90 (very light), and
onPrimaryContainer is tone 10 (very dark). The dark theme inverts most
of this. primary becomes tone 80, onPrimary becomes tone
20. Once you see the pattern, the 30-odd color role names stop feeling like a maze.
What the role names mean in practice
The names follow a system, even if the system is at first opaque. Anything called
primary is your dominant brand color: filled buttons, the FAB, active
state indicators. onPrimary is whatever sits on top of a primary
background. The algorithm guarantees it has legal contrast.
primaryContainer is a much softer version meant for less prominent
containers like chip backgrounds or the background of a navigation rail.
onPrimaryContainer goes on top of that. Once you've
internalised the pattern, Secondary and Tertiary follow the same shape. Secondary is
for medium-emphasis accents. Tertiary is a contrasting accent for things that
should pop against your primary scheme.
The Error roles work the same way (error, onError,
errorContainer, onErrorContainer), though they don't
follow your seed. They're derived from a fixed red. That's intentional. Users
associate red with errors regardless of your brand color, and overriding that costs
more than it buys.
The surface roles are where most of your UI actually lives, because most of your UI
is cards, sheets, menus, and backgrounds. surface with
onSurface is the workhorse pair, by far the most-used in any real app.
surfaceVariant is a slightly tinted surface for visual hierarchy
(outlined text fields, dividers, secondary surfaces). background is
often the same color as surface in M3, which feels redundant the first
time you see it, but Google kept both for Material 2 migration compatibility.
outline and outlineVariant are for borders and dividers.
The variant is softer for decorative borders that shouldn't dominate.
inverseSurface / inverseOnSurface /
inversePrimary are for components that deliberately contrast with their
context. The classic example is a Snackbar: dark on a light app, light on a dark
app. scrim is the dimming overlay behind modal dialogs. And
surfaceTint is the M3 elevation overlay. Google removed shadows from
elevation in M3 and replaced them with a faint primary tint, a decision that mostly
works in light mode and is at best controversial in dark.
Dropping the output into your project
The Kotlin code from the tool above replaces the contents of
app/src/main/java/com/example/yourapp/ui/theme/Theme.kt, or wherever
your project keeps it. The two colorScheme values
(LightColors, DarkColors by convention) get pasted in. The
outer MyAppTheme Composable that wraps MaterialTheme stays
as it was. Then your activity calls it:
setContent {
MyAppTheme {
// Your composables here
}
}
In your composables, always read colors from
MaterialTheme.colorScheme.primary. Never hard-code
Color(0xFF6750A4). This is non-negotiable, because the whole point of
having a theme is that dark mode just works. Hard-coding any color anywhere in your
UI breaks that, and you'll only notice when a user complains about eye-searing white
in dark mode after they've already left a one-star review.
If you're coming from Material 2
The migration from M2's colors to M3's colorScheme isn't a
one-to-one rename, which catches a lot of people. primary stays
primary, but primaryVariant doesn't become anything
obvious. In M3 the closest analog is primaryContainer, but the meaning
shifted. In M2, the variant was "darker primary, for elevated app bars." In M3, the
container version means "softer primary, for less-prominent UI." It's not a darker
shade. It's a different role. The same shift happened for Secondary. Tertiary is
new. M2 had no third accent at all. M3 introduces it specifically for callouts that
need to stand apart from your primary scheme.
A more subtle change: M2 handled "medium emphasis text" by taking
onSurface and applying an alpha of 0.74 (or whatever the recommendation
of the year was). M3 introduces onSurfaceVariant as a dedicated
lower-emphasis text color, no alpha required. This matters because alpha-blended
text doesn't render the same on every device, and contrast checkers can't verify
blended text the way they can verify a solid color. Use
onSurfaceVariant and don't reach for the alpha modifier anymore.
Elevation is the other thing that shifted. M2 used drop shadows. M3 uses the
surfaceTint overlay. Your surface gets tinted progressively more by
your primary color as it elevates. Shadows still work if you set them explicitly,
but they're no longer the default. This is the M3 design choice I see developers
complain about most, especially for apps that look right in light mode but feel flat
in dark where the tint is barely visible. If that's you, override
tonalElevation on cards and sheets back to zero and use
shadowElevation manually. Material gives you both knobs. You don't have
to pick the default.
Things that will bite you
Picking a pale seed color is the most common mistake. The algorithm assumes you've
given it a saturated starting point. If your brand color is something like
#FFE0E0 (washed-out pink), the entire theme comes out washed-out, and
no amount of contrast checking will save you. Pick something with real chroma. The
algorithm handles tone scaling for you. If your brand is genuinely pastel, pick the
more-saturated version of the same hue as the seed, then accept that the generated
theme will be more vivid than the brand color alone.
Don't try to bypass the algorithm by editing the generated colors by hand. The whole
contract is that onPrimary has legal contrast against
primary because it was computed from the palette. The moment you change
one in isolation, you may have created an accessibility issue invisible until a user
with low vision reports it. If you need to change the result, change the seed and
regenerate the whole thing.
The contrast issue can also bite the other direction. A theme that looks fine in light mode can have surprising contrast problems in dark mode. The preview above shows both for exactly this reason. Run your seed through it twice. Once for the primary brand color, once for any secondary brand colors you want to expose. And actually look at the dark version, not just the light.
Brand-specific accent colors (custom warning yellow, a not-quite-primary brand
green) need harmonize() applied to them, otherwise they'll clash with
the generated palette. The Compose extension shifts the input color slightly toward
your seed's hue without changing its identity meaningfully. Skip this and your
custom warning yellow can look weirdly out of place next to the algorithm-generated
yellows in your theme.
What this tool doesn't generate
The output is the standard M3 1.x color scheme. What lightColorScheme()
and darkColorScheme() currently accept. Material 3 1.2 added the
"Expressive" surface container hierarchy (surfaceContainerLowest
through surfaceContainerHighest) for finer-grained surface elevation.
The tool doesn't currently emit those. They're computed the same way (additional
Neutral palette tones), but I haven't added them because most apps in the wild are
still on M3 1.0 / 1.1 and the new roles are optional even on 1.2+. If you want
them, the algorithm to derive them from your seed is in
material-color-utilities. I'll add them here if there's demand.
The bigger thing missing is Dynamic Color, what Google calls Material You, where
the user's wallpaper provides the seed. Dynamic Color can't be statically generated
because the wallpaper isn't known at build time. The right pattern is to use this
tool's output as a fallback for devices that don't support Material You
(Android < 12), and call dynamicLightColorScheme(context) /
dynamicDarkColorScheme(context) on devices that do. Most production
apps end up doing both, with an if-check on Android version.