Mert Özen Further With Every Line
Frontend Mobile with Flutter — #09

Theming and Material 3 in Flutter: ThemeData, ColorScheme, Dark Mode, the material_ui Package and Cupertino

Mert Özen Nov 15, 2026 9 min 4 views
Theming and Material 3 in Flutter: ThemeData, ColorScheme, Dark Mode, the material_ui Package and Cupertino

ColorScheme.fromSeed does not use the seed color as is; it generates separate light and dark palettes with readable contrast, and values with no slot in ColorScheme are added to the theme with ThemeExtension. With real test output we cover Bookstore's brown theme, dark mode, our own color tokens, the move to material_ui/cupertino_ui as the SDK's Material is deprecated in 3.50, adaptive widgets, and the Turkish locale with intl formatting.

Version note: The examples in this post were run with Flutter 3.47.8 (Dart 3.13.7), material_ui 1.11.0, cupertino_ui 1.1.6 and intl 0.20.3. The output comes from flutter test runs and from opening the web build in Playwright's headless Chromium; running on an emulator or a device was not done for this post.

Guess First

We are giving the mobile app of Bookstore, the online bookstore used as the shared example across this site's series, its own color. For a feel of books and paper we chose a dark brown and generated the whole palette from that one color:

final scheme = ColorScheme.fromSeed(
  seedColor: const Color(0xFF6B4E2E),
  brightness: Brightness.light,
);

Will scheme.primary, the color of buttons, the selected tab and highlights, be exactly #6B4E2E? Is the same color used in the dark theme? The answer to both is no, and the reason explains how Material 3 builds its color system.

ThemeData and ColorScheme.fromSeed

In Flutter the single source of the look is ThemeData: colors, text styles and the default appearance of every component live there. Material 3 has been the default since Flutter 3.16 (useMaterial3 is true if not given). Bookstore's theme lives in the lib/ui/core/themes folder recommended by the official architecture guide:

abstract final class AppTheme {
  /// The one color the whole palette is generated from.
  static const seed = Color(0xFF6B4E2E);

  static ThemeData light = _build(Brightness.light, BookstoreColors.light);
  static ThemeData dark = _build(Brightness.dark, BookstoreColors.dark);

  static ThemeData _build(Brightness brightness, BookstoreColors colors) {
    final scheme = ColorScheme.fromSeed(
      seedColor: seed,
      brightness: brightness,
    );
    return ThemeData(
      colorScheme: scheme,
      extensions: [colors],
      cardTheme: const CardThemeData(margin: EdgeInsets.symmetric(vertical: 4)),
      chipTheme: ChipThemeData(side: BorderSide(color: scheme.outlineVariant)),
    );
  }
}

ColorScheme.fromSeed does not use the seed color directly. It takes a hue and chroma from the color and generates tonal palettes, then for each role (primary, onPrimary, surface...) picks tones of different lightness from those palettes for the light and dark themes. The default variant is DynamicSchemeVariant.tonalSpot. In a test we printed the colors of both schemes and the contrast between primary and the text on top of it:

light: primary #845416, onPrimary #FFFFFF, surface #FFF8F4, onSurface #211A14, primary/onPrimary contrast 6.4
dark: primary #FABA73, onPrimary #482900, surface #19120C, onSurface #EEE0D5, primary/onPrimary contrast 7.7

Here is the answer to "Guess First": primary is #845416 in the light theme and a light tone, #FABA73, in the dark theme. The seed is an input, not the result. The benefit is in the output too: every role pair comes with a readable contrast (WCAG asks for at least 4.5 for normal text). If the brand's exact color must appear somewhere as is, you can override it with copyWith(primary: ...), but then checking the contrast is up to you.

Component themes such as cardTheme and chipTheme change the default for every place that uses that component. Instead of giving the card margin on every Card, we give it once here. Rather than class-based styling as in CSS, it is an approach similar to the theme tokens in Angular Material.

Dark Mode: themeMode and the Platform Setting

MaterialApp takes two themes, and themeMode decides which one is used:

MaterialApp(
  title: 'Bookstore',
  theme: AppTheme.light,
  darkTheme: AppTheme.dark,
  themeMode: ThemeMode.system,
  localizationsDelegates: GlobalMaterialLocalizations.delegates,
  supportedLocales: const [Locale('en'), Locale('tr')],
  home: const HomeScreen(),
)

ThemeMode.system follows the device setting; light and dark are for pinning a user preference. In a test we changed the platform brightness and opened the app:

platform Brightness.dark -> theme Brightness.dark, price color #8FD8A6
platform Brightness.light -> theme Brightness.light, price color #1B6B3A

We compiled the same build for the web, opened it in Playwright's Chromium with prefers-color-scheme light and dark, and read the color of a pixel from the app bar background:

prefers-color-scheme: light -> app bar pixel #FFF8F4
prefers-color-scheme: dark -> app bar pixel #19120C

The values match the surface colors from the test. When the user changes the device setting the app does not restart; MaterialApp moves to the new theme with AnimatedTheme within 200 ms, and every widget that calls Theme.of(context) is rebuilt. If you want to give users a "light / dark / system" choice inside the app, you read themeMode from state; we will do that in the state management post.

Our Own Tokens with ThemeExtension

The roles in ColorScheme are generic: primary, secondary, error, surface... There is no slot for app-specific values such as Bookstore's price color or the color of the e-book badge. Instead of writing them into widgets as fixed colors, we add them to the theme as a ThemeExtension; this is Flutter's counterpart of the tokens in a design system:

/// Bookstore's own color tokens that ColorScheme has no slot for.
@immutable
class BookstoreColors extends ThemeExtension<BookstoreColors> {
  const BookstoreColors({required this.price, required this.ebookBadge});

  final Color price;
  final Color ebookBadge;

  static const light = BookstoreColors(
    price: Color(0xFF1B6B3A),
    ebookBadge: Color(0xFF7A4FB5),
  );

  static const dark = BookstoreColors(
    price: Color(0xFF8FD8A6),
    ebookBadge: Color(0xFFD3B8FF),
  );

  /// Falls back to the defaults when a theme without the extension is used
  /// (a test or a preview that builds its own MaterialApp).
  static BookstoreColors of(BuildContext context) {
    final theme = Theme.of(context);
    return theme.extension<BookstoreColors>() ??
        (theme.brightness == Brightness.dark ? dark : light);
  }

  @override
  BookstoreColors copyWith({Color? price, Color? ebookBadge}) =>
      BookstoreColors(
        price: price ?? this.price,
        ebookBadge: ebookBadge ?? this.ebookBadge,
      );

  @override
  BookstoreColors lerp(BookstoreColors? other, double t) {
    if (other == null) return this;
    return BookstoreColors(
      price: Color.lerp(price, other.price, t)!,
      ebookBadge: Color.lerp(ebookBadge, other.ebookBadge, t)!,
    );
  }
}

copyWith and lerp are required. lerp produces the value between two values while the theme changes; AnimatedTheme calls it throughout the transition. Get it wrong and the price color jumps abruptly when switching to dark mode. The fallback in of came from a pitfall: before we added the extension, some tests built their own MaterialApp, and extension<BookstoreColors>()! hit null and failed five tests. In a test we also checked the contrast of each theme's price color against the surface, and the value halfway between them:

light: price on surface 6.2
dark: price on surface 11.1
lerp 0.5 price: #55A270

Pinning the contrast of your own tokens with a test keeps readability from silently breaking when someone later makes a color "a bit lighter".

Using the Theme in Widgets

Widgets read colors and text styles from the theme, not from fixed values. The price line in the catalog card:

Text(
  context.formatPrice(book.price),
  style: textTheme.titleSmall?.copyWith(
    color: BookstoreColors.of(context).price,
  ),
),

textTheme comes from Theme.of(context).textTheme. Material 3's type scale consists of the display, headline, title, body and label groups, each in large, medium and small. When changing a style, derive it from the theme's style with copyWith instead of creating a new TextStyle; this keeps the user's text size setting and the color change in dark mode. Calling Theme.of(context) makes the widget depend on the theme, which is why it is rebuilt when the theme changes.

material_ui and cupertino_ui: the Separated Packages

Since the start of the series we have imported package:material_ui/material_ui.dart instead of package:flutter/material.dart. The reason is that Flutter is separating its design libraries from the SDK. According to the migration guide in the Flutter docs, the timeline is:

VersionWhat happened
Flutter 3.44The Material and Cupertino code inside the SDK was frozen; no new development
Flutter 3.47material_ui and cupertino_ui 1.0 were published on pub.dev; migration is opt-in
Flutter 3.50 (2026-11-11)The libraries inside the SDK are formally deprecated; no removal date has been given yet

The series is written on 3.47, and the current stable release is 3.50.0; we collect the 3.50 differences in the last post of the series. The packages are now versioned independently of the SDK: material_ui has gone from 1.0 in 3.47 to 1.11.0. You do not need to upgrade Flutter for a Material fix; upgrading the package is enough.

To migrate an existing project, dart fix --apply --code=migrate_design_widgets changes the imports. As we saw in the second post, naming the code is required; a plain dart fix --dry-run says "Nothing to fix!". There are two details in the migration. The first is localization: instead of GlobalMaterialLocalizations from flutter_localizations, the class of the same name in material_ui is used, and GlobalMaterialLocalizations.delegates provides all three delegates, Material, Cupertino and widgets. The second is packages that have not migrated yet: if a dependency still uses the old material.dart, wrapping the app with MaterialUiCompatibilityBridge in MaterialApp.builder keeps that package's Theme.of and MaterialLocalizations.of calls working. The bridge does not solve type mismatches: a package whose public API takes the SDK's ColorScheme or TextTheme type must migrate first.

In a new project the decision is simple: start with material_ui, plus cupertino_ui if needed. In Bookstore, cupertino_ui is a dev dependency for now, because only a test uses it.

Adaptive Widgets and Cupertino

Some Material widgets have an .adaptive constructor: on iOS and macOS they switch to the platform's look, elsewhere they use the Material look. We changed the indicator in LoadingView to CircularProgressIndicator.adaptive(); on iOS it shows Cupertino's spinning ticks. We opened Switch.adaptive on two platforms:

TargetPlatform.android -> 1 Switch, 0 CupertinoSwitch, size Size(60.0, 48.0)
TargetPlatform.iOS -> 1 Switch, 0 CupertinoSwitch, size Size(60.0, 48.0)

On both platforms there is a single Switch in the tree and no CupertinoSwitch is created; the outer size (including the touch target) is the same too. The difference is in the painting: according to the material_ui source, an adaptive Switch switches to Cupertino dimensions (a 51 x 31 track, a large thumb with a shadow) and Cupertino color rules when the platform is iOS or macOS. It reads the platform from ThemeData.platform; that is why we changed it with debugDefaultTargetPlatformOverride in the test.

When to go fully Cupertino: if the app ships only to iOS, or if the behavior iOS users are used to (swiping from the edge to go back, action sheets that slide up from the bottom, the Cupertino date picker) matters for the product. Bookstore ships to both platforms with the same brand; Material 3 is the base, with adaptive widgets where platform-specific behavior is needed. Writing two separate widget trees means testing every screen twice.

The Turkish Locale, flutter_localizations and intl

The localizationsDelegates and supportedLocales we gave MaterialApp choose Material's own strings (OK, Cancel, the month names in the date picker, screen reader labels) by the device language. Our own strings and the money and date formats come from the intl package. We wrote a small extension that formats price and date by locale:

/// Formats money and dates for the locale the app is shown in.
extension LocaleFormatting on BuildContext {
  String _locale() => Localizations.localeOf(this).toLanguageTag();

  String formatPrice(Money price) => NumberFormat.currency(
    locale: _locale(),
    name: 'TRY',
    symbol: '₺',
  ).format(price.kurus / 100);

  String formatDate(DateTime date) => DateFormat.yMMMMd(_locale()).format(date);
}
en: OK / Cancel, November 15, 2026, ₺489.00
tr: Tamam / İptal, 15 Kasım 2026, ₺489,00

The decimal separator and the month name come from the locale. Money keeps kuruş as an int internally; conversion to double happens only at display time and for two decimals, and calculations are always done with int. To translate the app's own strings ("Add to cart", "Try again"), Flutter has the gen-l10n tool, which generates code from ARB files; Bookstore's UI strings stay in English in this series. The API side of language settings is covered in the "Localization in Web APIs: Accept-Language, RequestLocalization, Localized Validation and ProblemDetails Messages" post of the ASP.NET Core Web API series.

In Production

Open every screen in both themes and with large text. The most common dark mode bug is a fixed color that does not come from the theme (say, text written with Colors.black) disappearing on a dark background. flutter analyze does not catch it; turning on the dark theme with platformBrightnessTestValue in a widget test does.

Manage colors from one place. A Color(0xFF...) expression should only appear under lib/ui/core/themes. If you see a fixed color in any other file during code review, move it either to a role in ColorScheme or to a field of a ThemeExtension.

Track package versions. Because material_ui is versioned separately, fixes arrive separately too; the 1.11.0 changelog includes a fix for a bug in the first paint of Switch.adaptive and documentation of how RefreshIndicator behaves with a mouse. Check regularly with flutter pub outdated.

Common Pitfalls

The first pitfall is assuming the seed color is the primary color. fromSeed generates a palette; if you need the brand's exact color, override it explicitly and measure the contrast.

The second pitfall is brushing off a ThemeExtension's lerp with return this;. It compiles, but the value jumps without animation during a theme change.

The third pitfall is giving only theme and forgetting darkTheme. In that case ThemeMode.system uses the light theme in dark mode too, and the user's setting is ignored.

The fourth pitfall is using NumberFormat without a locale. Flutter does not set Intl.defaultLocale to the device language: when we tried it in an app opened with locale: Locale('tr'), Intl.defaultLocale stayed null, NumberFormat used en_US, and the price came out as ₺489.00.

A Small Experiment

★☆☆ Give fromSeed in AppTheme dynamicSchemeVariant: DynamicSchemeVariant.fidelity and run theme_test.dart. Did the light theme's primary get closer to the seed color, and what happened to the contrast?

★★☆ Add a color called discount to BookstoreColors (for discounted prices) and write the contrast test for it too. Choose light and dark values that stay above 4.5.

★★★ Write a formatRelative that shows a date in relative form ("2 gün önce" / "2 days ago") instead of DateFormat.yMMMMd. To be able to test time zone differences, take the current time as a parameter (the Clock approach from the fourth post).

In the next post we take input from the user: a sign-in form and a delivery address form with Form and TextFormField, validation, moving between fields with FocusNode, the keyboard covering the content, and mapping ProblemDetails errors returned by the server to fields.