Suppose the design team changes the app's primary blue. You search the project and find the old color in buttons, icons, links, selected tabs, loading indicators, and a few places where it was copied with a different opacity.
The problem is not that the color changed. The problem is that widgets chose raw colors instead of asking the theme for the color that matches their purpose.
Start with meaning#
These names describe color values:
const blue600 = Color(0xFF246BFD);
const gray900 = Color(0xFF171A21);
const red600 = Color(0xFFD92D20);
These names describe how the app uses them:
const primary = Color(0xFF246BFD);
const onPrimary = Colors.white;
const surface = Colors.white;
const onSurface = Color(0xFF171A21);
const error = Color(0xFFD92D20);
const onError = Colors.white;
The second group gives widgets a stable contract. A button needs primary and onPrimary. It does not need to know which shade of blue the current brand uses.
Use ColorScheme for standard Material roles#
Flutter already has a semantic color model in ColorScheme:
final lightColorScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF246BFD),
brightness: Brightness.light,
).copyWith(
surface: const Color(0xFFF8F9FC),
error: const Color(0xFFD92D20),
);
final lightTheme = ThemeData(
colorScheme: lightColorScheme,
scaffoldBackgroundColor: lightColorScheme.surface,
useMaterial3: true,
);
Material widgets now use the same primary, surface, and error roles. Feature widgets should read these values from the theme:
final colors = Theme.of(context).colorScheme;
return Container(
color: colors.surface,
child: Text(
'Account settings',
style: TextStyle(color: colors.onSurface),
),
);
Avoid importing a color constants file directly into every screen. That bypasses dark mode and makes local color choices difficult to control.
Add only the roles your product needs#
ColorScheme may not cover every product-specific role. For example, your app may need colors for success messages and warning banners.
A ThemeExtension can add those roles:
@immutable
class StatusColors extends ThemeExtension<StatusColors> {
const StatusColors({
required this.success,
required this.onSuccess,
required this.warning,
required this.onWarning,
});
final Color success;
final Color onSuccess;
final Color warning;
final Color onWarning;
@override
StatusColors copyWith({
Color? success,
Color? onSuccess,
Color? warning,
Color? onWarning,
}) {
return StatusColors(
success: success ?? this.success,
onSuccess: onSuccess ?? this.onSuccess,
warning: warning ?? this.warning,
onWarning: onWarning ?? this.onWarning,
);
}
@override
StatusColors lerp(StatusColors? other, double t) {
if (other == null) return this;
return StatusColors(
success: Color.lerp(success, other.success, t)!,
onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
warning: Color.lerp(warning, other.warning, t)!,
onWarning: Color.lerp(onWarning, other.onWarning, t)!,
);
}
}
Flutter's ThemeExtension documentation explains why copyWith and lerp are required.
Register the extension in ThemeData:
final theme = ThemeData(
colorScheme: lightColorScheme,
extensions: const [
StatusColors(
success: Color(0xFF027A48),
onSuccess: Colors.white,
warning: Color(0xFFFDB022),
onWarning: Color(0xFF3D2C00),
),
],
);
Then use it where the role is needed:
final statusColors = Theme.of(context).extension<StatusColors>()!;
return ColoredBox(
color: statusColors.success,
child: Text(
'Payment completed',
style: TextStyle(color: statusColors.onSuccess),
),
);
Create an extension only for roles that appear across the product. A color used once inside an illustration does not need to become part of the application theme.
Build dark mode as a separate palette#
Setting brightness to dark does not automatically transform the custom colors in a ThemeExtension. Provide values designed for dark surfaces:
final darkColorScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF8AB4FF),
brightness: Brightness.dark,
);
const darkStatusColors = StatusColors(
success: Color(0xFF6CE9A6),
onSuccess: Color(0xFF053321),
warning: Color(0xFFFEC84B),
onWarning: Color(0xFF3D2C00),
);
final darkTheme = ThemeData(
colorScheme: darkColorScheme,
extensions: const [darkStatusColors],
useMaterial3: true,
);
Connect both themes to MaterialApp:
MaterialApp(
theme: lightTheme,
darkTheme: darkTheme,
themeMode: ThemeMode.system,
home: const HomePage(),
);
Open the actual app in both modes. Dark mode is not simply the light palette with every value inverted.
Put repeated component decisions in the theme#
If every FilledButton repeats the same radius and padding, configure it once:
final theme = ThemeData(
colorScheme: lightColorScheme,
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
);
Screens can now use normal FilledButton and TextField widgets without rebuilding the design system each time.
Treat typography the same way#
Use TextTheme roles instead of repeating font details:
Text(
'Recent activity',
style: Theme.of(context).textTheme.titleLarge,
);
Text(
'Updated two minutes ago',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
);
Names such as titleLarge and bodySmall communicate how text is used. A raw font size does not.
Check accessibility with real color pairs#
Every background color needs a readable foreground:
- primary with onPrimary;
- surface with onSurface;
- error with onError;
- success with onSuccess.
Test those pairs rather than checking colors independently. Also remember that color alone should not communicate state. An invalid field should include an error message or icon, and a selected control should have more than a subtle color change.
Test the theme with large text, disabled controls, and both brightness modes. A theme is successful when complete screens remain readable, not only when its color values look good in a palette.
Using Fabrik for this#
fabrik_theme provides semantic color and typography extensions and creates Material themes from them.
Whether you use it or build on ThemeData directly, keep feature widgets focused on semantic roles. Then a rebrand or dark-mode adjustment happens in the theme instead of across dozens of screens.