Theming #
Theming is the visual foundation of an app — it defines colors, typography, shapes, and component styles centrally so they’re consistent throughout the app. Flutter uses ThemeData as the container for all these visual decisions, and since Flutter 3.16, Material 3 (M3) is the default. Understanding Flutter’s theming system means you can change the entire app’s appearance with just a few lines of code.
ThemeData — The App’s Visual Container #
ThemeData holds all the visual properties affecting the whole app: colors, typography, shapes, and per-component themes. The appearance of Material 3 components is primarily determined by the ThemeData.colorScheme and ThemeData.textTheme values.
MaterialApp(
theme: ThemeData(
// Material 3 is the default since Flutter 3.16
useMaterial3: true,
// Color scheme from a single seed color
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A73E8),
),
// Typography
textTheme: const TextTheme(
displayLarge: TextStyle(fontSize: 57, fontWeight: FontWeight.bold),
titleLarge: TextStyle(fontSize: 22, fontWeight: FontWeight.w600),
bodyMedium: TextStyle(fontSize: 14),
),
),
home: const HomeScreen(),
)
Material 3 and ColorScheme #
The appearance of Material 3 components is primarily determined by the ThemeData.colorScheme and ThemeData.textTheme values. ColorScheme makes it easy to create dark and light schemes so your app is aesthetic while also meeting accessibility requirements.
ColorScheme.fromSeed — Automatic Scheme #
Material 3 lets users set the app’s entire color theme from a single seed color. By setting the seed color parameter in the theme constructor, Flutter generates a harmonious color scheme for every widget in your app from this entry.
ColorScheme.fromSeed(
seedColor: const Color(0xFF1A73E8), // the main brand color
// Flutter automatically generates:
// primary, onPrimary, primaryContainer, onPrimaryContainer
// secondary, onSecondary, secondaryContainer, onSecondaryContainer
// tertiary, onTertiary, tertiaryContainer, onTertiaryContainer
// error, onError, errorContainer, onErrorContainer
// surface, onSurface, surfaceVariant, onSurfaceVariant
// outline, shadow, inverseSurface, etc.
)
Manual ColorScheme — Full Control #
const ColorScheme lightScheme = ColorScheme(
brightness: Brightness.light,
// Primary color -- for FABs, main buttons, active elements
primary: Color(0xFF006E4A),
onPrimary: Colors.white,
primaryContainer: Color(0xFF8FF8C8),
onPrimaryContainer: Color(0xFF002115),
// Secondary color -- for filter chips, supporting elements
secondary: Color(0xFF4D6357),
onSecondary: Colors.white,
secondaryContainer: Color(0xFFCFE9DA),
onSecondaryContainer: Color(0xFF0A1F16),
// Tertiary color -- contrast accents
tertiary: Color(0xFF3D6473),
onTertiary: Colors.white,
tertiaryContainer: Color(0xFFC1E9FB),
onTertiaryContainer: Color(0xFF001F2A),
// Surface -- component backgrounds
surface: Color(0xFFFBFDF9),
onSurface: Color(0xFF191C1A),
// Error
error: Color(0xFFBA1A1A),
onError: Colors.white,
errorContainer: Color(0xFFFFDAD6),
onErrorContainer: Color(0xFF410002),
// Outline and shadow
outline: Color(0xFF717970),
);
The Role of Colors in Material 3 #
Each color in the ColorScheme has a role:
primary -- main color (buttons, FAB, active AppBar)
onPrimary -- text/icon ON TOP of primary (must be contrastive)
primaryContainer -- lighter version of primary (chips, card headers)
onPrimaryContainer -- text on top of primaryContainer
surface -- card, dialog, sheet backgrounds
onSurface -- main text on top of surface
surfaceVariant -- surface variant for containers
onSurfaceVariant -- secondary text on top of surfaceVariant
error -- error color
onError -- text on top of error
Dark Mode — ThemeData for Dark Themes #
Flutter natively supports light and dark themes through two parameters in MaterialApp:
MaterialApp(
// Theme for light mode
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A73E8),
brightness: Brightness.light,
),
),
// Theme for dark mode
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A73E8),
brightness: Brightness.dark, // Flutter automatically generates appropriate colors
),
),
// When to use theme vs darkTheme
themeMode: ThemeMode.system, // follow system settings (default)
// ThemeMode.light -- always light
// ThemeMode.dark -- always dark
home: const HomeScreen(),
)
Dynamic Dark Mode — User Toggle #
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
ThemeMode _themeMode = ThemeMode.system;
void _toggleTheme() {
setState(() {
_themeMode = _themeMode == ThemeMode.light
? ThemeMode.dark
: ThemeMode.light;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: _themeMode,
home: HomeScreen(onToggleTheme: _toggleTheme),
);
}
}
// Access brightness in a widget
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Icon(
isDark ? Icons.light_mode : Icons.dark_mode,
);
}
TextTheme — Consistent Typography #
TextTheme defines a typography hierarchy based on semantic roles — not hardcoded sizes:
ThemeData(
textTheme: const TextTheme(
// Display -- very large headings (hero sections, splash)
displayLarge: TextStyle(fontSize: 57, fontWeight: FontWeight.w400),
displayMedium: TextStyle(fontSize: 45, fontWeight: FontWeight.w400),
displaySmall: TextStyle(fontSize: 36, fontWeight: FontWeight.w400),
// Headline -- page and section titles
headlineLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.w400),
headlineMedium: TextStyle(fontSize: 28, fontWeight: FontWeight.w400),
headlineSmall: TextStyle(fontSize: 24, fontWeight: FontWeight.w400),
// Title -- card, dialog, app bar titles
titleLarge: TextStyle(fontSize: 22, fontWeight: FontWeight.w500),
titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
titleSmall: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
// Body -- main content text
bodyLarge: TextStyle(fontSize: 16, fontWeight: FontWeight.w400),
bodyMedium: TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
bodySmall: TextStyle(fontSize: 12, fontWeight: FontWeight.w400),
// Label -- buttons, tabs, chips
labelLarge: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
labelMedium: TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
labelSmall: TextStyle(fontSize: 11, fontWeight: FontWeight.w500),
),
)
// Use in widgets -- not hardcoded sizes!
Text(
'Page Title',
style: Theme.of(context).textTheme.titleLarge, // semantic, not px
)
Text(
'Article content...',
style: Theme.of(context).textTheme.bodyMedium,
)
Google Fonts in TextTheme #
import 'package:google_fonts/google_fonts.dart';
ThemeData(
textTheme: GoogleFonts.interTextTheme(
// Optional: override some styles
Theme.of(context).textTheme.copyWith(
displayLarge: GoogleFonts.playfairDisplay(
fontSize: 57,
fontWeight: FontWeight.bold,
),
),
),
)
Component Themes — Per-Component Overrides #
Besides colorScheme and textTheme, ThemeData has many component themes for customizing the appearance of specific components:
ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
// AppBar theme
appBarTheme: const AppBarTheme(
centerTitle: false,
elevation: 0,
scrolledUnderElevation: 2,
titleTextStyle: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
// ElevatedButton theme
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
// OutlinedButton theme
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
// Card theme
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade200),
),
),
// InputDecoration theme (global TextField)
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
filled: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
// NavigationBar theme
navigationBarTheme: NavigationBarThemeData(
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
elevation: 2,
),
// BottomSheet theme
bottomSheetTheme: const BottomSheetThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
),
// Chip theme
chipTheme: const ChipThemeData(
shape: StadiumBorder(),
),
)
Accessing the Theme in Widgets #
To understand how widgets fetch the theme from context, we can refer to the following theme resolution flow diagram:
flowchart TD
Widget["Widget: Theme.of(context)"] --> SearchNearest{"Find the Nearest Theme Widget in the Parent Tree"}
SearchNearest -->|Found| HasTheme["Use Local Theme (Override/Extended)"]
SearchNearest -->|Not Found| UseGlobal["Use Global Theme (MaterialApp.theme)"]
HasTheme --> ApplyTheme["Apply Colors & Styles to Components"]
UseGlobal --> ApplyTheme@override
Widget build(BuildContext context) {
// Access the entire ThemeData
final theme = Theme.of(context);
// Access the ColorScheme
final colors = Theme.of(context).colorScheme;
// Access the TextTheme
final texts = Theme.of(context).textTheme;
return Container(
color: colors.primaryContainer,
child: Text(
'Hello',
style: texts.titleLarge!.copyWith(
color: colors.onPrimaryContainer,
),
),
);
}
Local Theme Override with the Theme Widget #
To change the theme only in a specific part of the widget tree without affecting the whole app:
// Override the entire ThemeData (does not inherit from the parent)
Theme(
data: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.pink)),
child: const FloatingActionButton(
onPressed: null,
child: Icon(Icons.add),
),
)
// Extend (inherits from the parent and overrides partially) -- more recommended
Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: Colors.red,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
),
),
child: const DangerZoneSection(),
)
ThemeExtension — Adding Custom Colors to the Theme #
ThemeExtension lets you add custom values to the theme system — for example, semantic colors that don’t exist in the standard ColorScheme:
// Define the extension
@immutable
class AppColors extends ThemeExtension<AppColors> {
final Color? success;
final Color? warning;
final Color? infoBackground;
final Color? premiumCard;
const AppColors({
required this.success,
required this.warning,
required this.infoBackground,
required this.premiumCard,
});
// copyWith for immutability
@override
AppColors copyWith({
Color? success,
Color? warning,
Color? infoBackground,
Color? premiumCard,
}) {
return AppColors(
success: success ?? this.success,
warning: warning ?? this.warning,
infoBackground: infoBackground ?? this.infoBackground,
premiumCard: premiumCard ?? this.premiumCard,
);
}
// lerp for interpolation between themes (dark/light transition)
@override
AppColors lerp(AppColors? other, double t) {
if (other is! AppColors) return this;
return AppColors(
success: Color.lerp(success, other.success, t),
warning: Color.lerp(warning, other.warning, t),
infoBackground: Color.lerp(infoBackground, other.infoBackground, t),
premiumCard: Color.lerp(premiumCard, other.premiumCard, t),
);
}
}
// Register in ThemeData
ThemeData(
extensions: [
AppColors(
success: const Color(0xFF2E7D32),
warning: const Color(0xFFF57C00),
infoBackground: const Color(0xFFE3F2FD),
premiumCard: const Color(0xFFFFD700),
),
],
)
// Access in widgets
final appColors = Theme.of(context).extension<AppColors>()!;
Container(
color: appColors.success,
child: const Icon(Icons.check, color: Colors.white),
)
Organizing Theme Code #
Separate theme definitions into their own file for readability:
// lib/core/theme/app_theme.dart
class AppTheme {
static ThemeData get light => ThemeData(
useMaterial3: true,
colorScheme: _lightColorScheme,
textTheme: _textTheme,
appBarTheme: _appBarTheme,
elevatedButtonTheme: _elevatedButtonTheme,
cardTheme: _cardTheme,
inputDecorationTheme: _inputDecorationTheme,
extensions: [_lightAppColors],
);
static ThemeData get dark => ThemeData(
useMaterial3: true,
colorScheme: _darkColorScheme,
textTheme: _textTheme,
appBarTheme: _appBarTheme,
elevatedButtonTheme: _elevatedButtonTheme,
cardTheme: _cardTheme,
inputDecorationTheme: _inputDecorationTheme,
extensions: [_darkAppColors],
);
static const _lightColorScheme = ColorScheme(
brightness: Brightness.light,
primary: Color(0xFF1A73E8),
onPrimary: Colors.white,
// ... all colors
);
static const _darkColorScheme = ColorScheme(
brightness: Brightness.dark,
primary: Color(0xFF8AB4F8),
onPrimary: Color(0xFF003580),
// ... all colors
);
static final _textTheme = TextTheme(
titleLarge: GoogleFonts.inter(fontSize: 22, fontWeight: FontWeight.w600),
bodyMedium: GoogleFonts.inter(fontSize: 14),
labelLarge: GoogleFonts.inter(fontSize: 14, fontWeight: FontWeight.w500),
);
static const _lightAppColors = AppColors(
success: Color(0xFF2E7D32),
warning: Color(0xFFF57C00),
infoBackground: Color(0xFFE3F2FD),
premiumCard: Color(0xFFFFD700),
);
static const _darkAppColors = AppColors(
success: Color(0xFF4CAF50),
warning: Color(0xFFFF9800),
infoBackground: Color(0xFF0D2137),
premiumCard: Color(0xFFFFD700),
);
// Component themes as static getters
static const _appBarTheme = AppBarTheme(
centerTitle: false,
elevation: 0,
);
static final _elevatedButtonTheme = ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
static final _cardTheme = CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
);
static final _inputDecorationTheme = InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
filled: true,
);
}
Summary #
ThemeDatais the app’s visual container — define it inMaterialApp.themeandMaterialApp.darkTheme. Material 3 is the default since Flutter 3.16.ColorScheme.fromSeedautomatically generates a harmonious color scheme from a single seed color — available in light and dark versions.ThemeModecontrols when the dark theme is used:system(follows the OS),light, ordark. Programmatic toggling usessetState.TextThemedefines a typography hierarchy based on semantic roles (displayLarge, titleLarge, bodyMedium, etc.) — use these roles instead of hardcoding font sizes.- Component themes (
appBarTheme,elevatedButtonTheme,cardTheme,inputDecorationTheme, etc.) globally customize the appearance of specific components.- The
Themewidget enables local theme overrides — useTheme.of(context).copyWith()to inherit from the parent theme.ThemeExtensionadds custom colors or values to the theme system — very useful for semantic colors (success, warning) that don’t exist in the standardColorScheme.- Organize themes into a separate file (
app_theme.dart) with staticlightanddarkgetters for better readability and maintainability.