Flutter Widgets Fundamentals

Theming & Styling

50 min Lesson 16 of 18

Introduction to Flutter Theming

Flutter’s theming system provides a powerful, centralized way to define the visual appearance of your entire application. Instead of styling each widget individually, you define a ThemeData object that sets colors, typography, shapes, and component styles app-wide. Every widget in your tree can then access these values through Theme.of(context), ensuring consistency across your entire design system.

With Material 3 (Material Design 3), Flutter introduced a modern theming approach built around ColorScheme and dynamic color. This lesson covers everything you need to build a professional, maintainable theming system.

Material 3 Default: As of Flutter 3.16+, Material 3 is enabled by default. All new apps use the Material 3 design system unless explicitly opted out with useMaterial3: false.

ThemeData: The Foundation

ThemeData is the central configuration object that defines your app’s visual properties. It’s passed to the theme parameter of MaterialApp.

Basic ThemeData Setup

import 'package:flutter/material.dart';

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Themed App',
      theme: ThemeData(
        // Material 3 is default in Flutter 3.16+
        useMaterial3: true,

        // ColorScheme defines all colors used by Material components
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF1E88E5),  // Your brand color
          brightness: Brightness.light,
        ),

        // Typography
        textTheme: const TextTheme(
          headlineLarge: TextStyle(
            fontSize: 32,
            fontWeight: FontWeight.bold,
          ),
          bodyLarge: TextStyle(fontSize: 16),
          labelLarge: TextStyle(
            fontSize: 14,
            fontWeight: FontWeight.w600,
          ),
        ),

        // Component-level customization
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ElevatedButton.styleFrom(
            padding: const EdgeInsets.symmetric(
              horizontal: 24,
              vertical: 12,
            ),
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(8),
            ),
          ),
        ),
      ),
      home: const HomeScreen(),
    );
  }
}

ColorScheme: Material 3 Color System

In Material 3, ColorScheme is the primary way to define colors. It generates a harmonious set of colors from a single seed color, ensuring visual coherence across all components.

ColorScheme.fromSeed

// Generate a complete color scheme from a single seed color
final lightScheme = ColorScheme.fromSeed(
  seedColor: const Color(0xFF6750A4),  // Purple seed
  brightness: Brightness.light,
);

final darkScheme = ColorScheme.fromSeed(
  seedColor: const Color(0xFF6750A4),
  brightness: Brightness.dark,
);

// The generated scheme includes:
// primary, onPrimary, primaryContainer, onPrimaryContainer
// secondary, onSecondary, secondaryContainer, onSecondaryContainer
// tertiary, onTertiary, tertiaryContainer, onTertiaryContainer
// error, onError, errorContainer, onErrorContainer
// surface, onSurface, surfaceVariant, onSurfaceVariant
// outline, outlineVariant, shadow, scrim
// inverseSurface, onInverseSurface, inversePrimary

Custom ColorScheme with Brand Colors

// For precise brand control, customize specific colors
final brandScheme = ColorScheme.fromSeed(
  seedColor: const Color(0xFF1565C0),
).copyWith(
  // Override specific colors while keeping harmony
  primary: const Color(0xFF1565C0),
  secondary: const Color(0xFFFF6F00),
  tertiary: const Color(0xFF2E7D32),
  error: const Color(0xFFD32F2F),
);

// Or build a fully custom scheme
final customScheme = const ColorScheme(
  brightness: Brightness.light,
  primary: Color(0xFF1565C0),
  onPrimary: Color(0xFFFFFFFF),
  primaryContainer: Color(0xFFD1E4FF),
  onPrimaryContainer: Color(0xFF001D36),
  secondary: Color(0xFFFF6F00),
  onSecondary: Color(0xFFFFFFFF),
  secondaryContainer: Color(0xFFFFE0B2),
  onSecondaryContainer: Color(0xFF2B1700),
  surface: Color(0xFFFFFBFF),
  onSurface: Color(0xFF1C1B1F),
  error: Color(0xFFD32F2F),
  onError: Color(0xFFFFFFFF),
  errorContainer: Color(0xFFFFDAD6),
  onErrorContainer: Color(0xFF410002),
  outline: Color(0xFF79747E),
  outlineVariant: Color(0xFFCAC4D0),
  shadow: Color(0xFF000000),
  scrim: Color(0xFF000000),
);
Tip: ColorScheme.fromSeed() uses the HCT (Hue, Chroma, Tone) color space to generate perceptually uniform colors. The seed color determines the hue, and the algorithm creates harmonious primary, secondary, and tertiary palettes with proper contrast ratios for accessibility.

TextTheme: Typography System

Material 3 defines a structured typography system with specific text styles for different purposes. The TextTheme class organizes these into semantic roles.

Complete TextTheme Configuration

import 'package:google_fonts/google_fonts.dart';

final appTextTheme = TextTheme(
  // Display styles -- Large, prominent text
  displayLarge: GoogleFonts.poppins(
    fontSize: 57,
    fontWeight: FontWeight.w400,
    letterSpacing: -0.25,
  ),
  displayMedium: GoogleFonts.poppins(
    fontSize: 45,
    fontWeight: FontWeight.w400,
  ),
  displaySmall: GoogleFonts.poppins(
    fontSize: 36,
    fontWeight: FontWeight.w400,
  ),

  // Headline styles -- Section headers
  headlineLarge: GoogleFonts.poppins(
    fontSize: 32,
    fontWeight: FontWeight.w600,
  ),
  headlineMedium: GoogleFonts.poppins(
    fontSize: 28,
    fontWeight: FontWeight.w600,
  ),
  headlineSmall: GoogleFonts.poppins(
    fontSize: 24,
    fontWeight: FontWeight.w600,
  ),

  // Title styles -- Card titles, dialogs
  titleLarge: GoogleFonts.poppins(
    fontSize: 22,
    fontWeight: FontWeight.w500,
  ),
  titleMedium: GoogleFonts.poppins(
    fontSize: 16,
    fontWeight: FontWeight.w500,
    letterSpacing: 0.15,
  ),
  titleSmall: GoogleFonts.poppins(
    fontSize: 14,
    fontWeight: FontWeight.w500,
    letterSpacing: 0.1,
  ),

  // Body styles -- Paragraph text
  bodyLarge: GoogleFonts.inter(
    fontSize: 16,
    fontWeight: FontWeight.w400,
    letterSpacing: 0.5,
  ),
  bodyMedium: GoogleFonts.inter(
    fontSize: 14,
    fontWeight: FontWeight.w400,
    letterSpacing: 0.25,
  ),
  bodySmall: GoogleFonts.inter(
    fontSize: 12,
    fontWeight: FontWeight.w400,
    letterSpacing: 0.4,
  ),

  // Label styles -- Buttons, tabs, chips
  labelLarge: GoogleFonts.inter(
    fontSize: 14,
    fontWeight: FontWeight.w500,
    letterSpacing: 0.1,
  ),
  labelMedium: GoogleFonts.inter(
    fontSize: 12,
    fontWeight: FontWeight.w500,
    letterSpacing: 0.5,
  ),
  labelSmall: GoogleFonts.inter(
    fontSize: 11,
    fontWeight: FontWeight.w500,
    letterSpacing: 0.5,
  ),
);

Accessing Theme Data

Once your theme is configured, access it anywhere in the widget tree using Theme.of(context).

Using Theme.of(context)

class ProfileCard extends StatelessWidget {
  final String name;
  final String email;

  const ProfileCard({required this.name, required this.email, super.key});

  @override
  Widget build(BuildContext context) {
    // Access the current theme
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;
    final textTheme = theme.textTheme;

    return Card(
      // Card automatically uses theme's surface color and elevation
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              name,
              style: textTheme.headlineSmall?.copyWith(
                color: colorScheme.onSurface,
              ),
            ),
            const SizedBox(height: 4),
            Text(
              email,
              style: textTheme.bodyMedium?.copyWith(
                color: colorScheme.onSurfaceVariant,
              ),
            ),
            const SizedBox(height: 12),
            FilledButton(
              onPressed: () {},
              child: const Text('View Profile'),
            ),
          ],
        ),
      ),
    );
  }
}

Component-Level Theming

Material 3 allows you to customize the default appearance of every component through theme data. Each component has its own theme class.

Comprehensive Component Theming

ThemeData buildAppTheme() {
  final colorScheme = ColorScheme.fromSeed(
    seedColor: const Color(0xFF1E88E5),
  );

  return ThemeData(
    useMaterial3: true,
    colorScheme: colorScheme,

    // AppBar theme
    appBarTheme: AppBarTheme(
      centerTitle: true,
      elevation: 0,
      scrolledUnderElevation: 2,
      backgroundColor: colorScheme.surface,
      foregroundColor: colorScheme.onSurface,
      titleTextStyle: TextStyle(
        fontSize: 20,
        fontWeight: FontWeight.w600,
        color: colorScheme.onSurface,
      ),
    ),

    // Card theme
    cardTheme: CardTheme(
      elevation: 1,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12),
      ),
      clipBehavior: Clip.antiAlias,
    ),

    // Input decoration theme
    inputDecorationTheme: InputDecorationTheme(
      filled: true,
      fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.5),
      border: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12),
        borderSide: BorderSide.none,
      ),
      focusedBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12),
        borderSide: BorderSide(color: colorScheme.primary, width: 2),
      ),
      errorBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12),
        borderSide: BorderSide(color: colorScheme.error),
      ),
      contentPadding: const EdgeInsets.symmetric(
        horizontal: 16,
        vertical: 14,
      ),
    ),

    // Floating action button theme
    floatingActionButtonTheme: FloatingActionButtonThemeData(
      elevation: 4,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
      ),
    ),

    // Bottom navigation bar theme
    bottomNavigationBarTheme: BottomNavigationBarThemeData(
      type: BottomNavigationBarType.fixed,
      selectedItemColor: colorScheme.primary,
      unselectedItemColor: colorScheme.onSurfaceVariant,
      showUnselectedLabels: true,
    ),

    // Chip theme
    chipTheme: ChipThemeData(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(8),
      ),
    ),

    // Dialog theme
    dialogTheme: DialogTheme(
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(28),
      ),
    ),
  );
}

Custom Theme Extensions

When Material’s built-in theme properties aren’t enough, you can create custom theme extensions to add your own semantic colors, spacings, or any other design tokens.

Creating Custom Theme Extensions

// Define your custom theme extension
class AppColors extends ThemeExtension<AppColors> {
  final Color? success;
  final Color? onSuccess;
  final Color? warning;
  final Color? onWarning;
  final Color? info;
  final Color? onInfo;
  final Color? cardGradientStart;
  final Color? cardGradientEnd;

  const AppColors({
    this.success,
    this.onSuccess,
    this.warning,
    this.onWarning,
    this.info,
    this.onInfo,
    this.cardGradientStart,
    this.cardGradientEnd,
  });

  @override
  AppColors copyWith({
    Color? success,
    Color? onSuccess,
    Color? warning,
    Color? onWarning,
    Color? info,
    Color? onInfo,
    Color? cardGradientStart,
    Color? cardGradientEnd,
  }) {
    return AppColors(
      success: success ?? this.success,
      onSuccess: onSuccess ?? this.onSuccess,
      warning: warning ?? this.warning,
      onWarning: onWarning ?? this.onWarning,
      info: info ?? this.info,
      onInfo: onInfo ?? this.onInfo,
      cardGradientStart: cardGradientStart ?? this.cardGradientStart,
      cardGradientEnd: cardGradientEnd ?? this.cardGradientEnd,
    );
  }

  @override
  AppColors lerp(AppColors? other, double t) {
    if (other is! AppColors) return this;
    return AppColors(
      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),
      info: Color.lerp(info, other.info, t),
      onInfo: Color.lerp(onInfo, other.onInfo, t),
      cardGradientStart: Color.lerp(cardGradientStart, other.cardGradientStart, t),
      cardGradientEnd: Color.lerp(cardGradientEnd, other.cardGradientEnd, t),
    );
  }
}

// Register the extension in ThemeData
final theme = ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
  extensions: [
    const AppColors(
      success: Color(0xFF4CAF50),
      onSuccess: Color(0xFFFFFFFF),
      warning: Color(0xFFFFC107),
      onWarning: Color(0xFF000000),
      info: Color(0xFF2196F3),
      onInfo: Color(0xFFFFFFFF),
      cardGradientStart: Color(0xFF1E88E5),
      cardGradientEnd: Color(0xFF7C4DFF),
    ),
  ],
);

// Access the extension anywhere
class StatusBanner extends StatelessWidget {
  final String message;
  final StatusType type;

  const StatusBanner({required this.message, required this.type, super.key});

  @override
  Widget build(BuildContext context) {
    final appColors = Theme.of(context).extension<AppColors>()!;

    final (bgColor, fgColor) = switch (type) {
      StatusType.success => (appColors.success!, appColors.onSuccess!),
      StatusType.warning => (appColors.warning!, appColors.onWarning!),
      StatusType.info => (appColors.info!, appColors.onInfo!),
    };

    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: bgColor,
        borderRadius: BorderRadius.circular(8),
      ),
      child: Text(message, style: TextStyle(color: fgColor)),
    );
  }
}

enum StatusType { success, warning, info }
Important: The lerp method is required for smooth theme transitions (e.g., animating between light and dark themes). It interpolates between two instances of your extension at a given factor t (0.0 to 1.0).

Scoped Theming with Theme Widget

You can override the theme for a subtree of widgets using the Theme widget. This is useful for sections of your app that need a different visual treatment.

Scoped Theme Override

class PremiumSection extends StatelessWidget {
  const PremiumSection({super.key});

  @override
  Widget build(BuildContext context) {
    return Theme(
      // Override theme for this subtree only
      data: Theme.of(context).copyWith(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFFFFD700),  // Gold theme
          brightness: Theme.of(context).brightness,
        ),
        cardTheme: CardTheme(
          elevation: 4,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(16),
            side: const BorderSide(color: Color(0xFFFFD700), width: 2),
          ),
        ),
      ),
      child: Column(
        children: [
          // These widgets use the gold theme
          Card(
            child: ListTile(
              leading: const Icon(Icons.star),
              title: const Text('Premium Feature'),
              subtitle: const Text('Exclusive access'),
            ),
          ),
          FilledButton(
            onPressed: () {},
            child: const Text('Upgrade Now'),
          ),
        ],
      ),
    );
  }
}

Practical Example: Complete Brand Theme

Let’s build a complete, production-ready theme that demonstrates all the concepts together:

Production Brand Theme

// lib/theme/app_theme.dart
import 'package:flutter/material.dart';

class AppTheme {
  // Brand colors
  static const _brandBlue = Color(0xFF1565C0);
  static const _brandOrange = Color(0xFFFF6F00);

  // Spacing constants (not part of ThemeData, but useful)
  static const double spacingXs = 4;
  static const double spacingSm = 8;
  static const double spacingMd = 16;
  static const double spacingLg = 24;
  static const double spacingXl = 32;

  // Border radius constants
  static const double radiusSm = 8;
  static const double radiusMd = 12;
  static const double radiusLg = 16;
  static const double radiusXl = 28;

  static ThemeData get lightTheme {
    final colorScheme = ColorScheme.fromSeed(
      seedColor: _brandBlue,
      brightness: Brightness.light,
    );

    return _buildTheme(colorScheme);
  }

  static ThemeData get darkTheme {
    final colorScheme = ColorScheme.fromSeed(
      seedColor: _brandBlue,
      brightness: Brightness.dark,
    );

    return _buildTheme(colorScheme);
  }

  static ThemeData _buildTheme(ColorScheme colorScheme) {
    return ThemeData(
      useMaterial3: true,
      colorScheme: colorScheme,

      // Scaffold
      scaffoldBackgroundColor: colorScheme.surface,

      // AppBar
      appBarTheme: AppBarTheme(
        centerTitle: false,
        elevation: 0,
        backgroundColor: colorScheme.surface,
        foregroundColor: colorScheme.onSurface,
      ),

      // Cards
      cardTheme: CardTheme(
        elevation: 1,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(radiusMd),
        ),
      ),

      // Buttons
      filledButtonTheme: FilledButtonThemeData(
        style: FilledButton.styleFrom(
          padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(radiusSm),
          ),
        ),
      ),

      outlinedButtonTheme: OutlinedButtonThemeData(
        style: OutlinedButton.styleFrom(
          padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(radiusSm),
          ),
        ),
      ),

      // Input fields
      inputDecorationTheme: InputDecorationTheme(
        filled: true,
        fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.3),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(radiusSm),
          borderSide: BorderSide(color: colorScheme.outline),
        ),
        focusedBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(radiusSm),
          borderSide: BorderSide(color: colorScheme.primary, width: 2),
        ),
        contentPadding: const EdgeInsets.symmetric(
          horizontal: spacingMd,
          vertical: 14,
        ),
      ),

      // Divider
      dividerTheme: DividerThemeData(
        color: colorScheme.outlineVariant,
        thickness: 1,
        space: 1,
      ),

      // Extensions
      extensions: [
        AppColors(
          success: const Color(0xFF4CAF50),
          onSuccess: Colors.white,
          warning: const Color(0xFFFFC107),
          onWarning: Colors.black,
          info: colorScheme.primary,
          onInfo: colorScheme.onPrimary,
          cardGradientStart: colorScheme.primaryContainer,
          cardGradientEnd: colorScheme.tertiaryContainer,
        ),
      ],
    );
  }
}

// Usage in MaterialApp
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: AppTheme.lightTheme,
      darkTheme: AppTheme.darkTheme,
      themeMode: ThemeMode.system,
      home: const HomeScreen(),
    );
  }
}

Summary

Key Takeaways:
  • ThemeData is the central configuration for your app’s visual design, passed to MaterialApp.
  • ColorScheme.fromSeed() generates a complete, harmonious color palette from a single seed color.
  • TextTheme provides semantic text styles (display, headline, title, body, label) with three sizes each.
  • Theme.of(context) gives you access to the current theme anywhere in the widget tree.
  • Component themes (AppBarTheme, CardTheme, etc.) customize default styling for specific widgets.
  • ThemeExtension lets you add custom design tokens (success colors, spacings, etc.) that integrate with the theme system.
  • The Theme widget allows scoped overrides for subtrees that need different styling.
  • Organize your theme code in a dedicated class (AppTheme) with static methods for maintainability.