Building a custom theme system in Flutter
By Ann Tech · 16 July 2025
Flutter's built-in ThemeData covers most UI customization, but production apps often need things it doesn't support out of the box: per-brand color palettes in a white-label app, custom shadow tokens, or component-level styles that go beyond what Material exposes. Here is how to build a theme system that scales.
The problem with raw ThemeData
ThemeData is designed around Material Design. If your design system diverges from Material — and most product apps do — you end up fighting it. Common issues:
ColorSchemedoesn't have a slot for your "warning" or "brand secondary" colourTextThemenames (bodyMedium,labelLarge) don't map cleanly to your design system (paragraph,buttonLabel)- Component themes (
ElevatedButtonThemeData) are inflexible for custom widget variants
The solution: extend ThemeData with a parallel AppTheme extension, or replace it entirely with a custom inherited widget.
Approach 1: ThemeExtension (Flutter built-in)
Flutter 3.x added ThemeExtension — a typed extension mechanism:
@immutable
class AppThemeExtension extends ThemeExtension<AppThemeExtension> {
const AppThemeExtension({
required this.brandPrimary,
required this.brandSecondary,
required this.warning,
required this.success,
required this.cardShadow,
required this.borderRadius,
});
final Color brandPrimary;
final Color brandSecondary;
final Color warning;
final Color success;
final BoxShadow cardShadow;
final BorderRadius borderRadius;
@override
AppThemeExtension copyWith({
Color? brandPrimary,
Color? brandSecondary,
Color? warning,
Color? success,
BoxShadow? cardShadow,
BorderRadius? borderRadius,
}) =>
AppThemeExtension(
brandPrimary: brandPrimary ?? this.brandPrimary,
brandSecondary: brandSecondary ?? this.brandSecondary,
warning: warning ?? this.warning,
success: success ?? this.success,
cardShadow: cardShadow ?? this.cardShadow,
borderRadius: borderRadius ?? this.borderRadius,
);
@override
AppThemeExtension lerp(AppThemeExtension? other, double t) {
if (other is! AppThemeExtension) return this;
return AppThemeExtension(
brandPrimary: Color.lerp(brandPrimary, other.brandPrimary, t)!,
brandSecondary: Color.lerp(brandSecondary, other.brandSecondary, t)!,
warning: Color.lerp(warning, other.warning, t)!,
success: Color.lerp(success, other.success, t)!,
cardShadow: BoxShadow.lerp(cardShadow, other.cardShadow, t)!,
borderRadius: BorderRadius.lerp(borderRadius, other.borderRadius, t)!,
);
}
}
Register it in ThemeData:
ThemeData buildTheme() => ThemeData(
useMaterial3: true,
extensions: [
const AppThemeExtension(
brandPrimary: Color(0xFF0A1628),
brandSecondary: Color(0xFF2979FF),
warning: Color(0xFFF57C00),
success: Color(0xFF2E7D32),
cardShadow: BoxShadow(
color: Color(0x1A000000),
blurRadius: 8,
offset: Offset(0, 2),
),
borderRadius: BorderRadius.all(Radius.circular(12)),
),
],
);
Access it anywhere:
final ext = Theme.of(context).extension<AppThemeExtension>()!;
Container(
decoration: BoxDecoration(
color: ext.brandPrimary,
borderRadius: ext.borderRadius,
boxShadow: [ext.cardShadow],
),
);
Approach 2: Custom InheritedWidget (full control)
For white-label apps where themes switch per tenant:
class AppTheme extends InheritedWidget {
const AppTheme({super.key, required this.data, required super.child});
final AppThemeData data;
static AppThemeData of(BuildContext context) {
final theme = context.dependOnInheritedWidgetOfExactType<AppTheme>();
assert(theme != null, 'AppTheme not found in widget tree');
return theme!.data;
}
@override
bool updateShouldNotify(AppTheme old) => data != old.data;
}
@immutable
class AppThemeData {
const AppThemeData({
required this.colors,
required this.typography,
required this.spacing,
});
final AppColorScheme colors;
final AppTypography typography;
final AppSpacing spacing;
}
Usage:
final theme = AppTheme.of(context);
Text('Hello', style: theme.typography.heading);
Switching themes at runtime
class _AppState extends State<App> {
AppThemeData _theme = AppThemes.light;
@override
Widget build(BuildContext context) {
return AppTheme(
data: _theme,
child: MaterialApp(
home: Builder(builder: (ctx) => HomeScreen(
onToggleTheme: () => setState(() {
_theme = _theme == AppThemes.light
? AppThemes.dark
: AppThemes.light;
}),
)),
),
);
}
}
Per-flavor themes
For multi-brand or flavor-based theming:
class AppThemes {
static AppThemeData forFlavor(String flavor) => switch (flavor) {
'brand_a' => AppThemeData(
colors: AppColorScheme(
primary: const Color(0xFF003087),
accent: const Color(0xFFFFD700),
),
typography: AppTypography.defaultTypography,
spacing: AppSpacing.defaultSpacing,
),
'brand_b' => AppThemeData(
colors: AppColorScheme(
primary: const Color(0xFFB5121B),
accent: const Color(0xFFFFFFFF),
),
typography: AppTypography.defaultTypography,
spacing: AppSpacing.defaultSpacing,
),
_ => _defaultTheme,
};
}
Animating theme changes
ThemeData interpolation is built into MaterialApp via AnimatedTheme — theme switches automatically animate when you provide a new theme:
MaterialApp(
theme: isDark ? darkTheme : lightTheme,
// Theme changes animate automatically
)
For custom InheritedWidget themes, wrap with AnimatedSwitcher or use ThemeExtension.lerp manually.
Common pitfalls
Forgetting lerp in ThemeExtension. Without proper lerp implementations, animated theme transitions look broken — colours jump instead of transitioning.
Accessing theme outside MaterialApp. Theme.of(context) returns the default theme if called above MaterialApp in the widget tree. Always place theme-reading widgets below MaterialApp.
Too many theme variants. Start with light + dark. Add brand variants only when you have a real white-label requirement, not speculatively.
Theme and state mixed together. Theme is configuration, not state. Don't store user preferences (font size, high-contrast mode) in the theme object — store them in a settings provider and derive the theme from them.
Sign in to like, dislike, or report.