Skip to content
ExponaBLOG
FLUTTER · DAY 020 / 100

Theming with Material 3: Flutter lessons from production

Consistent design without repeating styles.

Expona Admin8 min read

By day 20 of building Flutter apps, you've probably copy-pasted button styles across screens or tweaked colors in multiple places. That's a sign it's time to learn proper theming. Theming with Material 3 gives you a systematic way to define colors, typography, and component styles once, then apply them everywhere—no repetition, no inconsistency, and a clean path to dark mode support.

Why Theming Matters in Production Apps

I've worked on projects where designers approved a color palette at project start, but by month three, we had five slightly different shades of blue scattered across the codebase. When the brand team decided to pivot the primary color, we spent days hunting down hardcoded values. Theming solves this at the architecture level.

In Material 3, your entire app's visual identity flows from a single theme definition. Change one value in your theme, and every button, text field, and card respects it. That's not just convenience—it's professional app development.

ColorScheme.fromSeed — Generate a Full Palette from One Brand Color

Material 3 introduced an elegant feature: generate a complete, harmonious color scheme from a single seed color. Designers love this because it ensures color harmony without manual tweaking.

Here's how it works in practice:

dart
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final seedColor = Color(0xFF6200EE); // Your brand primary

    return MaterialApp(
      title: 'Theming Demo',
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: seedColor,
          brightness: Brightness.light,
        ),
      ),
      darkTheme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: seedColor,
          brightness: Brightness.dark,
        ),
      ),
      themeMode: ThemeMode.system, // Respect device setting
      home: const HomeScreen(),
    );
  }
}

That single ColorScheme.fromSeed() call generates your primary, secondary, tertiary, surface, and error colors—all harmonized. I've used this on three production projects, and it's cut color management work by 80%. The generated palette is semantically sound; you're not fighting Material Design principles.

What you get from the seed:

  • Primary — Main brand color for high-emphasis actions.
  • Secondary — Accent for supporting actions and highlights.
  • Tertiary — Additional accent, often for toggles or badges.
  • Surface — Background for cards, dialogs, sheets.
  • Error — Validation feedback and destructive actions.

If you need to override specific colors, you can pass them directly:

dart
colorScheme: ColorScheme.fromSeed(
  seedColor: seedColor,
  brightness: Brightness.light,
  error: Color(0xFFB3261E), // Custom error if needed
),

Text Themes — Define Typography Once and Reuse

Consistency in typography matters as much as color. On an e-commerce app I worked on, we had body text in five different sizes because developers picked weights ad-hoc. Text themes prevent that.

Material 3 provides a predefined text theme hierarchy, and you extend it in your app theme:

dart
ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(seedColor: seedColor),
  textTheme: TextTheme(
    displayLarge: TextStyle(
      fontSize: 32,
      fontWeight: FontWeight.bold,
      color: Color(0xFF1C1B1F),
    ),
    displayMedium: TextStyle(
      fontSize: 28,
      fontWeight: FontWeight.bold,
      color: Color(0xFF1C1B1F),
    ),
    bodyLarge: TextStyle(
      fontSize: 16,
      fontWeight: FontWeight.normal,
      height: 1.5,
      color: Color(0xFF1C1B1F),
    ),
    bodyMedium: TextStyle(
      fontSize: 14,
      fontWeight: FontWeight.normal,
      color: Color(0xFF49454E),
    ),
    labelSmall: TextStyle(
      fontSize: 11,
      fontWeight: FontWeight.w500,
      letterSpacing: 0.5,
    ),
  ),
)

Then, anywhere in your app, use it like this:

dart
Text(
  'Welcome back, User',
  style: Theme.of(context).textTheme.displayMedium,
)

Text(
  'Order details and shipping information',
  style: Theme.of(context).textTheme.bodyMedium,
)

This approach couples your UI to the theme definition, not to hardcoded values. When a designer says "make headlines bigger," you change it in one place.

Pro tip: Use Google Fonts for consistency. I usually define my custom text theme in a separate file:

dart
// lib/theme/text_theme.dart
import 'package:google_fonts/google_fonts.dart';

TextTheme buildTextTheme(ColorScheme colorScheme) {
  return GoogleFonts.interTextTheme().copyWith(
    displayLarge: GoogleFonts.inter(
      fontSize: 32,
      fontWeight: FontWeight.bold,
      color: colorScheme.onBackground,
    ),
    // ... other styles
  );
}

Component Themes — Style Buttons, Inputs, and Cards Globally

Buttons, text fields, cards, and other components have their own theme properties. Set them once in your Material 3 theme, and every instance uses the same styling by default.

I'll walk through a real example from a trading platform I built:

dart
ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(seedColor: seedColor),
  
  // Elevated button (primary action)
  elevatedButtonTheme: ElevatedButtonThemeData(
    style: ElevatedButton.styleFrom(
      backgroundColor: colorScheme.primary,
      foregroundColor: colorScheme.onPrimary,
      padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
      textStyle: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
    ),
  ),
  
  // Text button (secondary action)
  textButtonTheme: TextButtonThemeData(
    style: TextButton.styleFrom(
      foregroundColor: colorScheme.primary,
      textStyle: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
    ),
  ),
  
  // Input decoration for text fields
  inputDecorationTheme: InputDecorationTheme(
    filled: true,
    fillColor: colorScheme.surface,
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(8),
      borderSide: BorderSide(color: colorScheme.outline),
    ),
    focusedBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(8),
      borderSide: BorderSide(color: colorScheme.primary, width: 2),
    ),
    errorBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(8),
      borderSide: BorderSide(color: colorScheme.error),
    ),
    contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
    labelStyle: TextStyle(color: colorScheme.onSurfaceVariant),
    hintStyle: TextStyle(color: colorScheme.outlineVariant),
  ),
  
  // Card theme
  cardTheme: CardTheme(
    color: colorScheme.surface,
    elevation: 1,
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  ),
)

Now, when you build a button or text field, you don't need to repeat styling:

dart
// Button automatically uses elevatedButtonTheme
ElevatedButton(
  onPressed: () => placeOrder(),
  child: Text('Place Order'),
)

// Text field automatically uses inputDecorationTheme
TextField(
  decoration: InputDecoration(
    labelText: 'Account Number',
    hintText: 'Enter your 10-digit number',
  ),
)

This is production-grade consistency. I've shipped ride-hailing and SaaS platforms with this pattern, and it makes scaling UI across multiple screens painless.

Dark Mode — Provide a Dark Theme and Respect System Settings

Modern apps are expected to support dark mode. With Material 3, it's as simple as defining a second theme for dark brightness.

Here's the complete setup:

dart
class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  ThemeMode _themeMode = ThemeMode.system;

  @override
  Widget build(BuildContext context) {
    final seedColor = Color(0xFF6200EE);

    return MaterialApp(
      title: 'Dark Mode App',
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: seedColor,
          brightness: Brightness.light,
        ),
        textTheme: buildTextTheme(
          ColorScheme.fromSeed(
            seedColor: seedColor,
            brightness: Brightness.light,
          ),
        ),
      ),
      darkTheme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: seedColor,
          brightness: Brightness.dark,
        ),
        textTheme: buildTextTheme(
          ColorScheme.fromSeed(
            seedColor: seedColor,
            brightness: Brightness.dark,
          ),
        ),
      ),
      themeMode: _themeMode,
      home: SettingsScreen(
        onThemeChanged: (mode) {
          setState(() => _themeMode = mode);
        },
      ),
    );
  }
}

The key is themeMode: ThemeMode.system at app startup. This respects the device's system setting. Users can override it in your settings screen:

dart
class SettingsScreen extends StatelessWidget {
  final Function(ThemeMode) onThemeChanged;

  const SettingsScreen({required this.onThemeChanged});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Settings')),
      body: ListView(
        children: [
          ListTile(
            title: Text('Theme'),
            trailing: DropdownButton<ThemeMode>(
              value: Theme.of(context).brightness == Brightness.dark
                  ? ThemeMode.dark
                  : ThemeMode.light,
              items: [
                DropdownMenuItem(
                  value: ThemeMode.system,
                  child: Text('System'),
                ),
                DropdownMenuItem(
                  value: ThemeMode.light,
                  child: Text('Light'),
                ),
                DropdownMenuItem(
                  value: ThemeMode.dark,
                  child: Text('Dark'),
                ),
              ],
              onChanged: (mode) {
                if (mode != null) onThemeChanged(mode);
              },
            ),
          ),
        ],
      ),
    );
  }
}

For dark mode, Material 3 automatically inverts the color scheme semantics. Your primary color becomes lighter (for readability on dark surfaces), and surfaces darken. You rarely need to manually adjust—the algorithm handles it.

Common Mistakes

  • Hardcoding colors in widgets — Developers often write Color(0xFF6200EE) inside a widget instead of using Theme.of(context).colorScheme.primary. This breaks theming. Always reach for the theme.
  • Forgetting useMaterial3: true — If you don't enable Material 3, you'll use the old Material 2 defaults, and the ColorScheme.fromSeed API won't apply correctly.
  • Duplicating text styles — Don't create custom TextStyle objects for every widget. Define them once in textTheme and reference them. I've debugged apps where developers had 20 different "headline" sizes.
  • Not testing dark mode — Define darkTheme but forget to test it. Always verify both light and dark variants work correctly. Surface colors especially need attention.
  • Ignoring semanticaly correct colors — Use onPrimary for text on primary backgrounds, onSurface for text on surfaces, etc. Don't just guess a color that looks good; Material 3 chose these for accessibility.

Key Takeaways

  • ColorScheme.fromSeed() generates a harmonious, complete palette from a single brand color—no manual color picking required.
  • Define all typography in textTheme once, then use Theme.of(context).textTheme throughout your app. This ensures consistency and makes global typography changes trivial.
  • Set component themes (buttons, inputs, cards) in your Material 3 theme definition. Every instance automatically respects the global style.
  • Support dark mode by providing a darkTheme with brightness: Brightness.dark. Use ThemeMode.system to respect device settings, and let users override in settings.
  • Always reference theme values via Theme.of(context) instead of hardcoding. This couples your UI to the theme, not to magic numbers.
  • Test both light and dark themes thoroughly. Pay attention to contrast and readability, especially with custom colors.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles