Lesson 28 of 51 intermediate

Design System & Theming

Consistent Look with ThemeData and Custom Design Systems

Open interactive version (quiz + challenge)

Real-world analogy

A design system is like a fashion brand's style guide. Just like Gucci has specific colors, fonts, and patterns that make everything look 'Gucci', your app's design system defines the exact colors, text styles, spacing, and component looks that make everything feel unified. ThemeData is Flutter's built-in style guide, and you can extend it with your own BaseDesignSystem class for complete control.

What is it?

Design system and theming in Flutter means using ThemeData to define your app's visual language: ColorScheme for colors, TextTheme for typography, component themes for widgets, and ThemeExtension for custom properties. Dark mode support comes from defining both theme and darkTheme. Advanced projects like team_mvp_kit use a custom BaseDesignSystem class to centralize all design tokens.

Real-world relevance

In team_mvp_kit, a BaseDesignSystem abstract class defines all visual constants. Concrete implementations (LightDesignSystem, DarkDesignSystem) provide theme-specific values. The toThemeData() method generates the ThemeData passed to MaterialApp. Every widget reads from Theme.of(context) instead of hardcoding values, making theme changes instant and global.

Key points

Code example

import 'package:flutter/material.dart';

// Custom design system for team_mvp_kit
class AppTheme {
  static ThemeData light() {
    final colorScheme = ColorScheme.fromSeed(
      seedColor: const Color(0xFF6750A4),
      brightness: Brightness.light,
    );

    return ThemeData(
      useMaterial3: true,
      colorScheme: colorScheme,
      textTheme: _textTheme,
      appBarTheme: AppBarTheme(
        backgroundColor: colorScheme.surface,
        foregroundColor: colorScheme.onSurface,
        elevation: 0,
        centerTitle: true,
      ),
      elevatedButtonTheme: ElevatedButtonThemeData(
        style: ElevatedButton.styleFrom(
          minimumSize: const Size(double.infinity, 48),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
          textStyle: const TextStyle(
            fontSize: 16,
            fontWeight: FontWeight.w600,
          ),
        ),
      ),
      inputDecorationTheme: InputDecorationTheme(
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(12),
        ),
        filled: true,
        fillColor: colorScheme.surfaceContainerHighest
            .withOpacity(0.3),
        contentPadding: const EdgeInsets.symmetric(
          horizontal: 16,
          vertical: 14,
        ),
      ),
      cardTheme: CardTheme(
        elevation: 0,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(16),
          side: BorderSide(
            color: colorScheme.outlineVariant,
          ),
        ),
      ),
      extensions: const [
        AppSpacing(),
        AppDurations(),
      ],
    );
  }

  static ThemeData dark() {
    final colorScheme = ColorScheme.fromSeed(
      seedColor: const Color(0xFF6750A4),
      brightness: Brightness.dark,
    );

    return ThemeData(
      useMaterial3: true,
      colorScheme: colorScheme,
      textTheme: _textTheme,
      // Same component themes with dark colors...
    );
  }

  static const _textTheme = TextTheme(
    headlineLarge: TextStyle(
      fontSize: 28,
      fontWeight: FontWeight.bold,
      letterSpacing: -0.5,
    ),
    titleLarge: TextStyle(
      fontSize: 20,
      fontWeight: FontWeight.w600,
    ),
    bodyLarge: TextStyle(
      fontSize: 16,
      height: 1.5,
    ),
    bodyMedium: TextStyle(
      fontSize: 14,
      height: 1.5,
    ),
    labelLarge: TextStyle(
      fontSize: 14,
      fontWeight: FontWeight.w600,
      letterSpacing: 0.5,
    ),
  );
}

class AppSpacing extends ThemeExtension<AppSpacing> {
  final double xs;
  final double sm;
  final double md;
  final double lg;
  final double xl;

  const AppSpacing({
    this.xs = 4,
    this.sm = 8,
    this.md = 16,
    this.lg = 24,
    this.xl = 32,
  });

  @override
  AppSpacing copyWith({
    double? xs, double? sm, double? md,
    double? lg, double? xl,
  }) {
    return AppSpacing(
      xs: xs ?? this.xs, sm: sm ?? this.sm,
      md: md ?? this.md, lg: lg ?? this.lg,
      xl: xl ?? this.xl,
    );
  }

  @override
  AppSpacing lerp(AppSpacing? other, double t) => this;
}

class AppDurations extends ThemeExtension<AppDurations> {
  final Duration fast;
  final Duration normal;

  const AppDurations({
    this.fast = const Duration(milliseconds: 200),
    this.normal = const Duration(milliseconds: 350),
  });

  @override
  AppDurations copyWith({Duration? fast, Duration? normal}) {
    return AppDurations(
      fast: fast ?? this.fast,
      normal: normal ?? this.normal,
    );
  }

  @override
  AppDurations lerp(AppDurations? other, double t) => this;
}

// Usage in MaterialApp:
// MaterialApp(
//   theme: AppTheme.light(),
//   darkTheme: AppTheme.dark(),
//   themeMode: ThemeMode.system,
// )

Line-by-line walkthrough

  1. 1. Import the Material package
  2. 2.
  3. 3. Define AppTheme class with static factory methods
  4. 4. Static light() method creates the light theme
  5. 5. Generate a ColorScheme from a seed color (purple)
  6. 6. Set brightness to light
  7. 7.
  8. 8. Build the ThemeData object
  9. 9. Enable Material Design 3
  10. 10. Apply the generated color scheme
  11. 11. Apply custom text theme (defined below)
  12. 12. AppBar theme: surface background, no elevation, centered title
  13. 13.
  14. 14.
  15. 15.
  16. 16.
  17. 17. Elevated button theme: full width, 48px height, rounded corners
  18. 18.
  19. 19.
  20. 20.
  21. 21.
  22. 22.
  23. 23.
  24. 24.
  25. 25.
  26. 26. Input decoration theme: rounded borders, filled background
  27. 27.
  28. 28.
  29. 29.
  30. 30.
  31. 31.
  32. 32.
  33. 33. Card theme: no elevation, rounded with border outline
  34. 34.
  35. 35.
  36. 36.
  37. 37.
  38. 38.
  39. 39. Register custom ThemeExtension classes
  40. 40. AppSpacing for consistent spacing values
  41. 41. AppDurations for animation timing
  42. 42.
  43. 43.
  44. 44.
  45. 45. Static dark() method creates the dark theme
  46. 46. Same seed color but Brightness.dark for dark palette
  47. 47.
  48. 48. ThemeData with dark color scheme
  49. 49. Same text theme works for both modes
  50. 50.
  51. 51.
  52. 52. Static text theme shared between light and dark
  53. 53. headlineLarge: 28px bold with tight letter spacing
  54. 54.
  55. 55.
  56. 56.
  57. 57. titleLarge: 20px semi-bold
  58. 58.
  59. 59.
  60. 60. bodyLarge: 16px with 1.5 line height
  61. 61.
  62. 62.
  63. 63. bodyMedium: 14px with 1.5 line height
  64. 64.
  65. 65.
  66. 66. labelLarge: 14px semi-bold with wide letter spacing
  67. 67.
  68. 68.
  69. 69.
  70. 70.
  71. 71. AppSpacing ThemeExtension with spacing scale
  72. 72. Define xs through xl spacing values as doubles
  73. 73.
  74. 74. Default values: 4, 8, 16, 24, 32
  75. 75.
  76. 76.
  77. 77.
  78. 78. copyWith for creating modified copies
  79. 79.
  80. 80.
  81. 81.
  82. 82. lerp returns this (spacing does not animate)
  83. 83.
  84. 84. AppDurations ThemeExtension for animations
  85. 85. fast and normal duration values
  86. 86.
  87. 87. Default: 200ms fast, 350ms normal
  88. 88.
  89. 89. copyWith for modified copies
  90. 90.
  91. 91.
  92. 92.
  93. 93. lerp returns this (durations do not interpolate)
  94. 94.
  95. 95.
  96. 96. Usage example in MaterialApp
  97. 97. Apply light and dark themes
  98. 98. Follow system theme mode

Spot the bug

Widget build(BuildContext context) {
  return Container(
    color: Colors.white,
    child: Text(
      'Hello',
      style: TextStyle(
        color: Colors.black,
        fontSize: 24,
      ),
    ),
  );
}
Need a hint?
This widget hardcodes colors. What happens in dark mode?
Show answer
Hardcoded Colors.white background and Colors.black text will not adapt to dark mode. In dark mode, the white container will clash with the dark theme. Fix: use Theme.of(context).colorScheme.surface for the background and Theme.of(context).textTheme.headlineSmall for the text style, so colors automatically adapt to the current theme.

Explain like I'm 5

Imagine you are the boss of a crayon factory. Instead of letting every artist pick random colors, you create an official crayon box with exactly the colors your brand uses: 'Brand Blue' for important things, 'Soft Grey' for backgrounds, and 'Alert Red' for warnings. ThemeData is your official crayon box. Every artist (widget) grabs colors from the same box, so all the drawings look like they belong together. Dark mode is like making a second crayon box with darker versions of the same colors for drawing at night!

Fun fact

Material Design 3 (Material You) can generate an entire color palette from a single seed color. ColorScheme.fromSeed uses the HCT (Hue, Chroma, Tone) color space to create harmonious primary, secondary, tertiary, and neutral color tones — the same algorithm that powers Android 12+ dynamic theming from your wallpaper!

Hands-on challenge

Create a complete AppTheme class with light() and dark() static methods. Define a ColorScheme.fromSeed, a custom TextTheme with at least 4 styles, component themes for ElevatedButton, Card, and InputDecoration, and a ThemeExtension for custom spacing values. Apply it to a MaterialApp with themeMode: ThemeMode.system and build a sample screen that uses only theme values (no hardcoded colors).

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter & Dart