← Back to blog
Flutter Theming Foundations · Part 3 of 6
August 26, 20269 min read

ColorScheme & Material You — Seed Colors and Color Roles Explained

FlutterDartTheming

ColorScheme & Material You

This is Part 3 of the Flutter Theming Foundations series, and it's the heart of the whole thing. In Part 2 we said colorScheme was the single most important property of ThemeData. Now we find out why.

If you take one idea from this series, make it this one: you don't pick app colors anymore — you pick a seed, and Flutter generates a complete, accessible palette of named roles from it. That's Material 3 ("Material You"), and it changes how you think about color.


The old way vs. the Material You way

The old mental model was "choose a primary color and an accent color, then hand-pick everything else." The Material You model is different:

Analogy — the paint chip vs. the color wheel. Old way: you bring one paint chip and then guess which other chips go with it. Material You: you bring one chip, and a color expert hands you a full, coordinated palette — light and dark variants, text colors that are guaranteed readable on each surface, borders, disabled states — all derived to be harmonious and accessible. You picked one color; you got thirty that work together.

You provide the seed; the algorithm provides the system.

final scheme = ColorScheme.fromSeed(seedColor: Colors.deepPurple);
// 'scheme' now holds ~30 coordinated, accessible colors.

Seed colors: one input, a whole palette

ColorScheme.fromSeed runs the seed color through Material 3's tonal-palette algorithm. It expands the seed into tonal palettes (the same hue at many lightness levels) and then assigns those tones to roles. The output meets Material's contrast requirements, so text-on-background pairs are readable by default.

// Minimal — light scheme from a seed.
ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4));

// With brightness — the *same seed* yields a coordinated dark palette.
ColorScheme.fromSeed(
  seedColor: const Color(0xFF6750A4),
  brightness: Brightness.dark,
);

The key insight: light and dark from the same seed stay on brand because they share a hue — they're not two unrelated palettes, they're two views of one. (We pair them into a switchable app in Part 5.)

Best practice: start with just a seed and don't override individual roles. The generated values are tuned to harmonize and pass contrast. Override only when a brand spec forces your hand — every manual override is a chance to break contrast.

You can also tune the generation a little:

ColorScheme.fromSeed(
  seedColor: Colors.teal,
  brightness: Brightness.light,
  // Optional knobs:
  // dynamicSchemeVariant: DynamicSchemeVariant.vibrant, // palette flavor
  // contrastLevel: 0.5, // 0.0 default → 1.0 high contrast (accessibility)
);

The roles: what each name means

A ColorScheme is a set of semantic roles, not raw colors. The names describe intent ("this is the primary action color"), which is exactly the semantic naming that defeats the "which blue?" problem from Part 1.

The roles come in groups, and within a group there's a repeating pattern:

| Role | Meaning | Typical use | | --- | --- | --- | | primary | Main brand/action color | FAB, primary buttons, active states | | onPrimary | Content drawn on primary | Label/icon on a primary button | | primaryContainer | A softer, filled primary surface | Chips, highlighted cards | | onPrimaryContainer | Content on primaryContainer | Text on those chips | | secondary / tertiary | Supporting accent colors (+ their on/container pairs) | Less-prominent accents | | surface | Default background of components/cards | Scaffold, cards, sheets | | onSurface | Primary text/icons on surfaces | Body text | | onSurfaceVariant | Lower-emphasis content on surfaces | Captions, secondary text, icons | | surfaceContainerLowest…Highest | A ladder of surface tints for elevation | Layered cards/sheets | | outline | Borders and dividers | OutlinedButton border, dividers | | outlineVariant | Subtler borders | Decorative dividers | | error / onError / errorContainer / onErrorContainer | Error states | Validation, destructive UI | | inverseSurface / onInverseSurface | Inverted surface (e.g. snackbars) | Snackbar background/text |

You don't memorize all of these — you learn the pattern and look the rest up.

The "on" rule (the most important pattern)

Rule: for every background role X, there's an onX role that is guaranteed-readable content to draw on top of X. Pair them.

// Draw a primary-colored banner with readable text on it.
Container(
  color: scheme.primary,
  child: Text('Sale', style: TextStyle(color: scheme.onPrimary)), // ✅ readable
)

// ❌ Don't pair a background with the wrong 'on' color:
Container(
  color: scheme.primary,
  child: Text('Sale', style: TextStyle(color: scheme.onSurface)), // may be unreadable
)

If you always pair X with onX, contrast takes care of itself — that's the whole point of the system.

The "container" pattern

primary is bold (a filled FAB). primaryContainer is the softer version (a tinted chip), with onPrimaryContainer for its text. Same idea for secondary/tertiary/error. Reach for the container roles when you want a gentle tint rather than a loud, saturated fill.

Chip(
  backgroundColor: scheme.secondaryContainer,
  label: Text('New', style: TextStyle(color: scheme.onSecondaryContainer)),
)

Material widgets already use these roles

Here's the magic dividend: Material components color themselves from the scheme automatically. A default FloatingActionButton is primaryContainer; a default ElevatedButton uses scheme colors; a Card sits on surface. So just by setting a good ColorScheme, most of your app is already themed correctly:

Scaffold(
  // surface comes from the scheme
  floatingActionButton: FloatingActionButton(   // primaryContainer/onPrimaryContainer
    onPressed: () {},
    child: const Icon(Icons.add),
  ),
  body: Card(child: ListTile(title: Text('Item'))), // surface + onSurface text
)

You mostly reach for scheme.x by hand for your custom widgets — the components Material doesn't know about.


Deprecated roles to avoid

Material 3 reorganized surfaces. A few old roles are deprecated — don't reach for them in new code:

| Deprecated | Use instead | | --- | --- | | background | surface | | onBackground | onSurface | | surfaceVariant | surfaceContainerHighest (or surfaceContainerHigh) |

If a tutorial tells you to use colorScheme.background, it predates the current surface roles. Use surface and the surfaceContainer* ladder instead.


Dynamic color (Material You, literally)

"Material You" originally meant pulling the palette from the user's wallpaper on Android 12+. The dynamic_color package gives you that wallpaper-derived scheme when available, with your seed scheme as fallback:

// Sketch — full usage in the package docs.
DynamicColorBuilder(
  builder: (lightDynamic, darkDynamic) {
    final light = lightDynamic ?? ColorScheme.fromSeed(seedColor: Colors.indigo);
    return MaterialApp(theme: ThemeData(colorScheme: light), home: const Home());
  },
)

The takeaway: because your app speaks in roles, you can swap the entire ColorScheme source — seed, wallpaper, or a brand config — and every widget follows, untouched. That swap-ability is exactly what the Riverpod series makes reactive.


Practice Challenges

Challenge 1 — Generate a scheme. Build a light ColorScheme from a green seed and a matching dark one from the same seed.

Show solution
final light = ColorScheme.fromSeed(seedColor: Colors.green);
final dark  = ColorScheme.fromSeed(
  seedColor: Colors.green,
  brightness: Brightness.dark,
);

Same seed → on-brand in both modes.

Challenge 2 — Pick the pair. You're drawing text on a primaryContainer background. Which role is the text color?

Show solution

onPrimaryContainer. The rule: content drawn on X uses onX. So primaryContaineronPrimaryContainer.

Challenge 3 — Fix the contrast bug. Container(color: scheme.error, child: Text('Oops', style: TextStyle(color: scheme.onSurface))) looks washed out. Fix it.

Show solution
Container(
  color: scheme.error,
  child: Text('Oops', style: TextStyle(color: scheme.onError)),
)

Pair error with onError, not onSurface.

Challenge 4 — Modernize. A snippet uses colorScheme.background and colorScheme.surfaceVariant. Replace with current roles.

Show solution

backgroundsurface; surfaceVariantsurfaceContainerHighest (or surfaceContainerHigh). Both old roles are deprecated in Material 3.

Challenge 5 — A soft badge. Make a "Beta" badge that's a gentle tertiary tint with readable text, not a loud fill.

Show solution
Container(
  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
  decoration: BoxDecoration(
    color: scheme.tertiaryContainer,
    borderRadius: BorderRadius.circular(8),
  ),
  child: Text('Beta', style: TextStyle(color: scheme.onTertiaryContainer)),
)

Container roles give the soft, accessible tint; the saturated tertiary would be the loud version.


Questions to test yourself

Q1 (basic). What does ColorScheme.fromSeed do?

Show answer

It takes one seed color and generates a full ColorScheme (~30 coordinated roles) using Material 3's tonal algorithm, with values tuned to harmonize and meet contrast requirements.

Q2 (basic). What is the on prefix convention (e.g. onPrimary)?

Show answer

For any background role X, onX is the color guaranteed to be readable when drawn on top of X. Always pair X with onX (e.g. text on a primary button uses onPrimary).

Q3 (intermediate). Why generate light and dark from the same seed instead of designing two palettes?

Show answer

They share a hue, so they stay on-brand and feel like two views of one identity rather than two unrelated themes — while each is independently tuned for its brightness and contrast.

Q4 (intermediate). What's the difference between primary and primaryContainer?

Show answer

primary is the bold, saturated brand color (filled FAB/buttons); primaryContainer is a softer, lower-emphasis tint of it (chips, highlighted cards). Their content colors are onPrimary and onPrimaryContainer respectively.

Q5 (intermediate). Why does setting a good ColorScheme theme most of your app "for free"?

Show answer

Built-in Material widgets color themselves from the scheme's roles (FAB → primaryContainer, cards → surface, etc.). So a correct scheme automatically styles all standard components; you only reach for roles by hand in your custom widgets.

Q6 (advanced). Why is the role-based design what makes wallpaper-based dynamic color (or whitelabel brands) possible without touching widgets?

Show answer

Widgets reference semantic roles (primary, surface, onSurface), not concrete hex. So you can swap the entire source of the ColorScheme — a seed, the user's wallpaper via dynamic_color, or a per-brand config — and every widget re-reads its role and updates, with zero widget-level changes. That swap-ability is what the Riverpod theming series turns into reactive runtime switching.


Wrapping up

  • Material 3 ("Material You") generates a whole palette from one seed via ColorScheme.fromSeed — accessible and harmonious by default.
  • A ColorScheme is semantic roles, not raw colors: primary/surface/error groups, each with an onX content color and often a container variant.
  • The on rule (pair X with onX) makes contrast automatic; the container roles give soft tints.
  • Material widgets self-color from the scheme, so a good scheme themes most of the app for free. Avoid deprecated background/surfaceVariant.

In Part 4 we give text the same treatment: TextTheme — a type scale your whole app shares.