Chuyển đến nội dung chính

Material 3 theming in Flutter: colour roles, not colour values

The Material 3 migration in Flutter produced a predictable pattern. Teams call ColorScheme.fromSeed(seedColor: brandPurple), look at the result, decide it is not their brand, and start passing explicit colours to every widget again. Six months later the app has two hundred hard-coded hex values, dark mode is a separate list of two hundred more, and changing the brand colour is a week of work.

The thing that was skipped is the idea Material 3 is actually built on: you do not style widgets with colours, you assign them roles. A FilledButton does not have a purple background — it has a primary background with onPrimary content. Once that is true across the app, the palette becomes one object you can swap.

The roles, and what each one is for

ColorScheme has about thirty members. They are not thirty independent choices; they are pairs and families.

RoleUsed forIts “on” pair
primaryThe main action, filled buttons, active statesonPrimary
primaryContainerA softer emphasis of the same ideaonPrimaryContainer
secondary / tertiaryAccents that need to differ from primaryonSecondary / onTertiary
surfaceEvery background: pages, cards, sheetsonSurface
surfaceContainerLowestHighestElevation expressed as tone, not shadowonSurface
errorDestructive and invalid statesonError
outline / outlineVariantBorders and dividers

The on prefix is the whole contract: onX is guaranteed readable on X. If you paint a container colorScheme.primaryContainer and its text colorScheme.onPrimaryContainer, contrast is handled — in light mode, in dark mode, and after someone changes the seed.

The one that changed most in Material 3 is surfaces. The old model raised elevation with a shadow; the new one raises it with tone. A dialog sitting on a page is a lighter (in light mode) or lighter-grey (in dark mode) surface, not a drop shadow. That is what the surfaceContainer* family is for, and it is why background and surfaceVariant were deprecated — use surface and surfaceContainerHighest.

When a seed is right, and when it is not

ColorScheme.fromSeed runs the Material colour algorithm: it takes one colour, derives a tonal palette, and picks roles from it that are guaranteed to meet contrast requirements. It is genuinely good, and it is the right default for an app without a strict brand book.

final lightScheme = ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4));
final darkScheme = ColorScheme.fromSeed(
  seedColor: const Color(0xFF6750A4),
  brightness: Brightness.dark,
);

Two things people get wrong here. The seed is not your primary colour — the algorithm derives primary from the seed’s hue and will happily return something noticeably different, because the exact seed may not have enough contrast against onPrimary. And you need two calls, one per brightness; a dark scheme is not the light one inverted.

When brand requirements are exact, do not fight the algorithm. Construct the scheme explicitly and let the analyser tell you what you missed:

const brandLight = ColorScheme(
  brightness: Brightness.light,
  primary: Color(0xFF0B5FFF),
  onPrimary: Color(0xFFFFFFFF),
  secondary: Color(0xFF00A37A),
  onSecondary: Color(0xFF00110B),
  error: Color(0xFFBA1A1A),
  onError: Color(0xFFFFFFFF),
  surface: Color(0xFFFDFCFF),
  onSurface: Color(0xFF1A1C1E),
  // ...
);

A middle path that works well in practice: seed the scheme, then copyWith only the two or three roles the brand actually pins. You keep the algorithm’s contrast guarantees everywhere else.

There is also ColorScheme.fromImageProvider, which derives a scheme from an image asynchronously — useful for a player screen that themes itself from album art, and a bad idea for your whole app, since it must be awaited.

Wiring it into ThemeData once

ThemeData _theme(ColorScheme scheme) => ThemeData(
      colorScheme: scheme,
      useMaterial3: true,
      textTheme: _textTheme,
      cardTheme: CardThemeData(
        elevation: 0,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
        color: scheme.surfaceContainerLow,
      ),
      filledButtonTheme: FilledButtonThemeData(
        style: FilledButton.styleFrom(
          minimumSize: const Size.fromHeight(48),
          shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
        ),
      ),
      inputDecorationTheme: const InputDecorationTheme(
        filled: true,
        border: OutlineInputBorder(),
      ),
      extensions: const [AppTokens.light],
    );

MaterialApp(
  theme: _theme(lightScheme),
  darkTheme: _theme(darkScheme),
  themeMode: ThemeMode.system,
  home: const HomePage(),
);

Component themes are where a design system stops being a document and becomes code. filledButtonTheme with a 48-pixel minimum height means nobody has to remember the tap-target rule. cardTheme with a 16-pixel radius means the corner radius is one line, not two hundred.

Note ThemeMode.system — respecting the OS setting is the expected default, and the two-theme setup means it costs nothing.

ThemeExtension for the tokens Material does not have

Every real design system has values Material has no slot for: a success colour, a brand gradient, a spacing scale, a chart palette. The wrong answer is a file of global constants, because they cannot vary by brightness. ThemeExtension is the right one:

@immutable
class AppTokens extends ThemeExtension<AppTokens> {
  const AppTokens({
    required this.success,
    required this.onSuccess,
    required this.spacingUnit,
  });

  final Color success;
  final Color onSuccess;
  final double spacingUnit;

  static const light = AppTokens(
    success: Color(0xFF116B3E),
    onSuccess: Color(0xFFFFFFFF),
    spacingUnit: 8,
  );

  static const dark = AppTokens(
    success: Color(0xFF7CDBA4),
    onSuccess: Color(0xFF00391E),
    spacingUnit: 8,
  );

  @override
  AppTokens copyWith({Color? success, Color? onSuccess, double? spacingUnit}) =>
      AppTokens(
        success: success ?? this.success,
        onSuccess: onSuccess ?? this.onSuccess,
        spacingUnit: spacingUnit ?? this.spacingUnit,
      );

  @override
  AppTokens lerp(AppTokens? other, double t) {
    if (other is! AppTokens) return this;
    return AppTokens(
      success: Color.lerp(success, other.success, t)!,
      onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
      spacingUnit: lerpDouble(spacingUnit, other.spacingUnit, t)!,
    );
  }
}

Reading it is a one-liner, and an extension makes it pleasant:

extension ThemeX on BuildContext {
  ColorScheme get colors => Theme.of(this).colorScheme;
  AppTokens get tokens => Theme.of(this).extension<AppTokens>()!;
}

// Container(color: context.tokens.success)

Implementing lerp is not optional busywork — it is what makes your custom tokens animate smoothly when the theme changes, exactly like the built-in ones. Skip it and a light/dark transition shows Material colours crossfading while your brand green snaps.

Typography, and the thing that breaks accessibility

Material 3’s TextTheme has fifteen named styles in five families — display, headline, title, body, label, each in Large/Medium/Small. Define them once and reference by role:

Text('Total', style: Theme.of(context).textTheme.titleMedium)

The rule that matters more than any of them: never set a font size that ignores the user’s text-scale setting, and never disable scaling to make a layout fit. If a label overflows at 200% scale, the layout is wrong, not the setting. Use Flexible, FittedBox where genuinely appropriate, and test at the extremes — the accessibility settings on both platforms go well past what most designs are checked against.

Migration order that avoids a rewrite

Turning useMaterial3 on in a mature app changes a lot at once. The sequence that keeps it reviewable:

  1. Switch to useMaterial3: true and fix only what is visually broken. Expect button shapes, app bar colouring and elevation to shift.
  2. Replace deprecated members: backgroundsurface, onBackgroundonSurface, surfaceVariantsurfaceContainerHighest.
  3. Grep for Color(0x outside your theme file. Each hit is either a role you should be using or a token that belongs in a ThemeExtension.
  4. Move per-widget styling into component themes. A styleFrom repeated in five places is a filledButtonTheme entry.
  5. Only then tune the palette. Doing this first means retuning after every later step.

FAQ

Should I still use primarySwatch?

No. It belongs to the Material 2 model and is ignored by most Material 3 components. Use ColorScheme.fromSeed, or an explicit ColorScheme.

Why does my button look different from the design after fromSeed?

Because the algorithm chose primary for contrast, not fidelity to your seed. If the exact value matters, copyWith(primary: ..., onPrimary: ...) after seeding — and check the contrast yourself, since you have opted out of the guarantee.

How do I theme one screen differently?

Wrap it in a Theme widget with a modified ThemeData. Everything below it, including dialogs opened from it, picks up the override — dialogs inherit from the context that opened them.

Is ThemeExtension worth it for three colours?

Yes, if those colours differ between light and dark. That is the whole distinction: constants cannot, extensions can, and the type safety means a missing token is a compile error rather than a wrong shade in production.

What replaced Theme.of(context).accentColor?

colorScheme.secondary, in most cases. The ThemeData colour fields from Material 2 are gone or deprecated; the ColorScheme is the single source now.


Role semantics and the deprecations are from the Flutter Material documentation and the Material 3 colour guidance linked above. The migration order, the seed-then-copyWith compromise, and the view that a layout breaking at high text scale is a layout bug are my own judgement. Material widget APIs change between Flutter releases — check the API docs for the SDK you ship.


Originally published on FlutterCook. Read the latest version there — that copy is the one kept up to date.

Nhận xét

Bài đăng phổ biến từ blog này

5 concepts every Flutter dev should know

  Phụ lục: State management architecture Testing IDE Shortcuts Platform channel Maintaining a project Tôi đã làm việc với Flagship trong một thời gian dài, và đây là những điều mà tôi phát hiện ra là điều cần phải có đối với bất kỳ nhà phát triển Flagship nào, về tổng thể nó sẽ khiến bạn trở thành một nhà phát triển Flagship giỏi trong thời gian dài. 1. State management architecture Đây là một trong những chủ đề quan trọng nhất trong cộng đồng thiết bị rung, nó khá quan trọng nếu bạn muốn duy trì một dự án rung kích thước trung bình hoặc lớn. Nó sẽ giúp tạo một dự án suôn sẻ và thêm các tính năng mới một cách hoàn hảo.  2. Testing Đây là một chủ đề duy nhất mà tôi không hiểu tại sao nó lại quan trọng trước đó trong sự nghiệp của tôi, nhưng khi tôi tiến lên trong sự nghiệp của mình và có kinh nghiệm với nhiều dự án và vấn đề xảy ra trong môi trường sản xuất. Tôi đã nhận ra một cách khó khăn, tại sao điều này lại quan trọng như vậy. Nếu bạn vẫn muốn có thêm lý do để cân nhắc thử...

Thiết kế giao diện với DotNetBar (Phần 1)

Đây là phiên bản DotNetBar hỗ trợ C# và Visual Basic https://www.dropbox.com/s/wx80jpvgnlrmtux/DotNetBar.rar  , phiên bản này hỗ trợ giao diện Metro cực kỳ “dễ thương” Các bạn load về và cài đặt, khi cài đặt xong sẽ có source code mẫu của tất cả các control. Để sử dụng được các control của DotNetBar các bạn nhớ add item vào controls box. Thiết kế giao diện với DotNetBar, giao diện sẽ rất đẹp. Link các video hướng dẫn chi tiết cách sử dụng và coding: http://www.devcomponents.com/dotnetbar/movies.aspx Hiện tại DotNetBar có rất nhiều công cụ cực mạnh, trong đó có 3 công cụ dưới đây: DotNetBar for Windows Forms Requires with Visual Studio 2003, 2005, 2008, 2010 or 2012.   DotNetBar for WPF Requires with Visual Studio 2010 or 2012 and Windows Presentation Foundation.   DotNetBar for Silverlight Requires with Visual Studio 2010 or 2012 and Silverlight. Dưới đây là một số hình ảnh về các control trong DotnetBar.   Metro User Interface  controls with Metro Tiles, toolba...

2026 👏 Google miễn phí một năm gói AI Plus cho sinh viên Việt Nam 🇻🇳🇻🇳🇻🇳

Chúc mừng anh em 👏 Google miễn phí một năm gói AI Plus cho sinh viên Việt Nam 🇻🇳🇻🇳🇻🇳 Đối tượng là sinh viên đại học từ 18–24 tuổi (bao gồm sinh viên mới và sinh viên từng dùng gói AI Pro 2025). Lưu ý là cần xác minh tư cách sinh viên hàng năm. Khi nhận gói, anh em có được các ưu đãi sau: 🔖 Nâng cấp bộ nhớ đám mây lên 400GB. 🔖 Nhân đôi hạn mức truy cập mô hình AI. 🔖 Mở quyền trải nghiệm tính năng tạo video bằng Gemini Omni. Lưu ý chương trình chỉ dành cho sinh viên đủ điều kiện. Hạn nhận ưu đãi: 31 tháng 12, 2026. Các bạn cần có phương thức thanh toán hợp lệ khi đăng ký. Google AI Plus sẽ tự động tính phí 132.000 ₫/tháng sau khi thời gian dùng thử kết thúc, trừ phi bạn đã huỷ trước đó. Huỷ bất cứ lúc nào. CÁC BƯỚC THỰC HIỆN CHI TIẾT 🔹Bước 1: Truy cập cổng đăng ký chương trình 🔖 Mở trình duyệt và truy cập vào trang chính thức: gemini.google/students/ 🔖 Đăng nhập vào Tài khoản Google cá nhân của bạn. 🔖 Nhấn vào nút "Claim your student plan at no cost" (hoặc Nhận gó...