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 #

  • ThemeData is the app’s visual container — define it in MaterialApp.theme and MaterialApp.darkTheme. Material 3 is the default since Flutter 3.16.
  • ColorScheme.fromSeed automatically generates a harmonious color scheme from a single seed color — available in light and dark versions.
  • ThemeMode controls when the dark theme is used: system (follows the OS), light, or dark. Programmatic toggling uses setState.
  • TextTheme defines 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 Theme widget enables local theme overrides — use Theme.of(context).copyWith() to inherit from the parent theme.
  • ThemeExtension adds custom colors or values to the theme system — very useful for semantic colors (success, warning) that don’t exist in the standard ColorScheme.
  • Organize themes into a separate file (app_theme.dart) with static light and dark getters for better readability and maintainability.

← Previous: Navigation   Next: Anti-Pattern →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact