Theming & Design Systems
ThemeData, ColorScheme.fromSeed, Material 3, ThemeExtension, custom fonts, dark mode
- 01
Your seed colour is ignored and the app renders in baseline purple, but only in the widget that builds MaterialApp — why?
EasyTheme.of(context)walks up from the context you hand it to the nearest Theme inherited widget, and MaterialApp installs yours below itself — so a context taken above MaterialApp finds nothing and gets the fallback theme.const brand = Color(0xFF00695C); // ❌ this context is ABOVE the MaterialApp the same method builds class App extends StatelessWidget { const App({super.key}); … - 02
Design hands you one brand hex, but ColorScheme.fromSeed returns a primary that is not that colour — what happened, and which roles do you build with?
EasyfromSeeddoes not use your hex asprimary; it treats it as a seed, derives tonal palettes from it in the HCT colour space, and reads each role off a fixed tone so every colour and itsonpartner are legible by construction.const brand = Color(0xFF00695C); final scheme = ColorScheme.fromSeed( seedColor: brand, // a seed, not the resulting primary brightness: Brightness.light, ); … - 03
Your screens build every heading with a hand-written TextStyle — what do the Material 3 TextTheme roles give you that this throws away?
EasyA role is a name your design system can redefine in one place; a literal
TextStyleis a copy of that decision now living in forty files.// ❌ one decision, copied into every screen Text('Monthly total', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w600)); // ✅ a role the theme owns Text('Monthly total', style: Theme.of(context).textTheme.titleLarge); … - 04
Your app-wide filledButtonTheme is set, but a screen that passes its own style now shows brand-coloured disabled buttons — how does a component style resolve?
MediumA Material component merges three styles property by property — the call site's
style, then the component theme from ThemeData, then the widget's built-in defaults — and for each property the first non-null value wins.const brand = Color(0xFF00695C); final theme = ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: brand), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( … - 05
Where do brand tokens with no ColorScheme slot — a success green, a hero gradient, a grid gutter — live so they still work when the theme changes?
MediumIn a
ThemeExtension<T>registered onThemeData.extensions, which makes them reachable throughTheme.of(context)exactly like the built-in roles and lets them interpolate while the theme animates.@immutable class BrandTokens extends ThemeExtension<BrandTokens> { const BrandTokens({ required this.success, required this.heroGradient, required this.gutter, … - 06
Your brand purple is unreadable on dark surfaces and the designer's dark theme is not the light one inverted — how do you build the two ThemeData objects?
MediumWrite one function that takes a
Brightnessand returns a complete ThemeData, and letColorScheme.fromSeedchoose different tones for each — the dark theme'sprimaryis a lighter tone of the same hue precisely because the mid tone is unreadable on a dark surface.const _brand = Color(0xFF6750A4); ThemeData buildTheme(Brightness brightness) { final cs = ColorScheme.fromSeed(seedColor: _brand, brightness: brightness); return ThemeData( colorScheme: cs, // brightness comes from here … - 07
Your headings ask for FontWeight.w700 but render at normal weight on the device — what is wrong with the font setup?
MediumFlutter matches a requested weight against the faces you declared in
pubspec.yamland falls back to the nearest one, so if the only Inter file you shipped is Regular, everyw700in the app renders at 400.# pubspec.yaml — every weight the design uses is a separate declared file flutter: uses-material-design: true fonts: - family: Inter … - 08
You are dropping useMaterial3: false from an app that never migrated — what visibly changes, and what regresses without anyone noticing?
MediumMaterial 3 swaps a
primaryColor-driven look for aColorScheme-driven one: components take colour from scheme roles, layers separate by surface tone instead of shadow, and the type scale and corner radii change underneath every screen.// ── Before: Material 2 wiring ── ThemeData( useMaterial3: false, // deprecated escape hatch primaryColor: const Color(0xFF3F51B5), // drove AppBar, FAB and buttons appBarTheme: const AppBarTheme(elevation: 4), ); … - 09
CupertinoSwitch and CupertinoButton inside your MaterialApp ignore the brand colour and the custom font — what does MaterialApp actually hand CupertinoTheme?
MediumMaterialApp exposes your ThemeData to Cupertino widgets through
MaterialBasedCupertinoThemeData, which bridges four values and leaves everything else at the iOS defaults.final theme = ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0B6E4F)), fontFamily: 'Inter', // Only brightness, primaryColor, primaryContrastingColor and // scaffoldBackgroundColor are derived from the Material theme above. … - 10
Product wants Material You colours from the Android wallpaper, marketing wants the brand purple — how do you ship both, and when do you refuse dynamic colour outright?
MediumFlutter has no built-in wallpaper palette: you read it with the
dynamic_colorpackage and fall back toColorScheme.fromSeed(brandSeed)on every platform and OS version that has none.import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; const _brandSeed = Color(0xFF6750A4); class App extends StatelessWidget { … - 11
The same FilledButton is visibly shorter in your macOS build than on the phone, with no platform code anywhere. What is doing that, and how do you take control?
MediumThemeDatadefaultsvisualDensitytoVisualDensity.defaultDensityForPlatform(platform), which isstandardon Android, iOS and Fuchsia andcompacton macOS, Windows and Linux.// The default nobody writes down: // android / ios / fuchsia -> VisualDensity.standard ( 0, 0) // macos / windows / linux -> VisualDensity.compact (-2, -2) = 8px smaller final inherited = ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0B6E4F)), ); … - 12
Three apps share one design system. What does that package actually export, and how do you change a token without breaking a release you don't control?
MediumIt exports semantic tokens, a
ThemeDatafactory and the components — never raw constants — and every change to that surface is versioned like any other public API.// packages/acme_design/lib/acme_design.dart — the entire public surface export 'src/theme/acme_theme.dart' show AcmeTheme; export 'src/theme/acme_tokens.dart' show AcmeTokens, AcmeSpacing, AcmeThemeX; export 'src/components/acme_empty_state.dart' show AcmeEmptyState; // src/** stays internal: apps cannot import it, so it is free to change. … - 13
When should a design system ship its own InheritedWidget instead of putting its tokens in a ThemeExtension, and what do you give up either way?
HardUse a
ThemeExtensionwhen the value is theme-shaped and should interpolate with the theme, and your ownInheritedWidgetwhen it is not — a scope with a different lifetime, a different change rate, or valuesThemeDatahas no business carrying.import 'dart:ui' show lerpDouble; import 'package:flutter/material.dart'; // ── Theme-shaped and lerpable: ThemeExtension ── @immutable class AcmeTokens extends ThemeExtension<AcmeTokens> { … - 14
Switching to dark mode flashes white for a frame and your brand gradient jumps instead of fading. Where does each of those come from?
HardMaterialAppalready wraps the app in anAnimatedThemethat lerpsThemeDataoverkThemeAnimationDuration(200 ms), so anything that jumps is either outside that widget or a valueThemeData.lerprefuses to interpolate.MaterialApp( theme: AcmeTheme.light(), darkTheme: AcmeTheme.dark(), themeMode: mode, // MaterialApp inserts AnimatedTheme for you; these are its knobs. themeAnimationDuration: const Duration(seconds: 1), // slow it down to debug … - 15
How do you stop a pull request from landing a raw Color(0xFF...) or an EdgeInsets.all(13) once the design system exists?
HardYou make it a build failure instead of a review comment: a custom lint that flags the raw literal, a checked-in baseline so the existing count can only fall, and per-theme goldens for the mistakes a lint cannot see.
// packages/acme_lints/lib/acme_lints.dart import 'package:analyzer/error/listener.dart'; import 'package:custom_lint_builder/custom_lint_builder.dart'; PluginBase createPlugin() => _AcmeLints(); … - 16
You inherit an app with 400 hard-coded colours and text styles, and a release freeze is not an option. How do you get it onto the design system?
HardYou strangle it feature by feature behind a compatibility layer: define the tokens first, point every legacy constant at one, then delete the constants a screen at a time while CI ratchets the remaining count down.
// 1 — inventory, before writing any code // grep -rEn "Color\(0x|Colors\.[a-z]" lib | wc -l -> 412 // grep -rEoh "Color\(0x[0-9A-Fa-f]{8}\)" lib | sort | uniq -c -> 31 distinct // 71 Color(0xFFF5F5F5) 58 Color(0xFF2E7D32) 44 Color(0xFFF6F6F6) // (0xFFF5F5F5 and 0xFFF6F6F6 are the same design decision, typed twice.) …