Flutter: Light and Dark Themes with ThemeData
9 min read

If you write the button color, the heading size or the card corner radius separately in every widget, the first design change means going through dozens of files. And when you want to add a dark theme, you're stuck. ThemeData collects these decisions in one place: colors, text styles and the default look of components. When widgets read values from the theme instead of hard-coding them, a dark theme comes almost for free. In this post I cover setting up a theme with Material 3, light and dark themes, reading values from the theme, component themes and adding your own design values to the theme.
A Color Scheme with ColorScheme.fromSeed
In Material 3 (the default since Flutter 3.16), colors come from a ColorScheme. Instead of choosing every color by hand, you provide a "seed" color and ColorScheme.fromSeed generates the whole scheme from it in harmonious tones:
import 'package:flutter/material.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.dark,
),
),
themeMode: ThemeMode.system,
home: const HomePage(),
);
}
}Because the dark scheme is generated from the same seed with brightness: Brightness.dark, the two themes look like members of the same family. Every color in the scheme has a role, and widgets are painted according to those roles:
| Role | Where it's used |
|---|---|
primary / onPrimary |
The main action: filled button, selected state; on... is the text and icons on top of it |
primaryContainer / onPrimaryContainer |
Emphasized but softer areas, highlighted cards |
secondary, tertiary (+ their containers) |
Secondary accents and balancing colors |
surface / onSurface |
Page and card background, the main text on it |
onSurfaceVariant |
Secondary text, descriptions |
surfaceContainerLow ... surfaceContainerHighest |
Surface tones that rise layer by layer |
outline, outlineVariant |
Borders and dividers |
error / onError |
Error states |
The rule is simple: if you use a color as a background, pick its on... counterpart for the text on top. That keeps contrast intact in both themes.
The seed color doesn't have to appear as primary exactly; fromSeed may soften it according to Material's tone system. If your brand color must show up faithfully, the dynamicSchemeVariant: DynamicSchemeVariant.fidelity parameter keeps the palettes closer to the seed. If you want to pin one role exactly, fromSeed also accepts that role directly as a parameter (for example primary: brandColor).
ThemeMode: System, Light, Dark
Three fields of MaterialApp work together: theme is the light theme, darkTheme the dark one, and themeMode decides which is used. ThemeMode.system follows the device setting; when the user switches the phone to dark mode, the app switches too. ThemeMode.light and ThemeMode.dark force one of them regardless of the system. If you don't provide darkTheme, theme is used whatever themeMode says. If you offer the user a choice, these three options usually appear together; leaving the default at system respects the preference the user made for the whole device.
Reading the Theme with Theme.of(context)
Defining the theme is half the job; the real payoff comes when widgets read their values from it:
class InfoCard extends StatelessWidget {
const InfoCard({super.key});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final text = Theme.of(context).textTheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colors.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Today's goal",
style: text.titleMedium?.copyWith(color: colors.onPrimaryContainer),
),
const SizedBox(height: 4),
Text(
'3 lessons, 45 minutes',
style: text.bodyMedium?.copyWith(
color: colors.onPrimaryContainer.withValues(alpha: 0.8),
),
),
],
),
);
}
}There isn't a single hard-coded color in this card; when the theme changes, the card changes with it. Since Flutter 3.27 there are also the shortcuts ColorScheme.of(context) and TextTheme.of(context), which return the same values. To change a color's transparency, use withValues(alpha: ...) instead of the old withOpacity.
A widget that calls Theme.of(context) subscribes to the theme: when the theme changes, Flutter rebuilds that widget automatically, with no setState from you. The lookup walks up the tree to the nearest Theme. That lets you change the theme for just one part of the app:
Theme(
data: Theme.of(context).copyWith(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.red,
brightness: Theme.of(context).brightness,
),
),
child: FilledButton(onPressed: () {}, child: const Text('Delete account')),
)TextTheme: Text Styles
In Material 3, text styles come in five groups with three sizes each:
| Group | Sizes | Typical use |
|---|---|---|
display |
Large, Medium, Small | Very large, short text (counters, welcome screens) |
headline |
Large, Medium, Small | Page and section headings |
title |
Large, Medium, Small | Card titles, AppBar, list headers |
body |
Large, Medium, Small | Paragraphs and general text |
label |
Large, Medium, Small | Button text, small labels |
You read styles like Theme.of(context).textTheme.titleLarge. When you pass a textTheme to the theme, only the fields you specify change; the rest are merged with the defaults. To change the font of the entire app, ThemeData(fontFamily: 'Inter') is enough; you have to declare the font in pubspec.yaml first. Old names like headline6 and bodyText1 no longer exist; their counterparts are the new names such as titleLarge and bodyLarge.
Component Themes: Defaults in One Place
ThemeData carries a separate theme field for each component: appBarTheme, filledButtonTheme, inputDecorationTheme, cardTheme, snackBarTheme and more. To avoid repeating the same decisions for the light and dark themes, building the theme in a function is a good habit:
ThemeData buildTheme(Brightness brightness) {
final colors = ColorScheme.fromSeed(
seedColor: const Color(0xFF3F51B5),
brightness: brightness,
);
return ThemeData(
colorScheme: colors,
textTheme: const TextTheme(
headlineSmall: TextStyle(fontWeight: FontWeight.w700),
titleMedium: TextStyle(fontWeight: FontWeight.w600),
),
appBarTheme: const AppBarTheme(centerTitle: false),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
filled: true,
),
cardTheme: CardThemeData(
elevation: 0,
color: colors.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outlineVariant),
),
),
snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating),
);
}Now every FilledButton in the app is full width with 12-pixel rounded corners, and every text field comes outlined and filled. If one place needs a different look, the widget's own parameter overrides the theme. I show the theme fields of these components in detail in the AppBar and NavigationBar posts.
What Goes in the Theme, What Goes in the Widget?
Moving every value into the theme creates its own mess. A practical rule that works well in class: if the same visual decision repeats in two or more places, it goes in the theme; if it's specific to one screen, it goes in that widget's parameter. Button corner radius, text field borders and card backgrounds should be the same everywhere in the app, so they live in the theme. The color of a one-off large heading on the welcome screen can stay on that screen, as long as it's still a role picked from colorScheme. Spacing values (8, 16, 24 and so on) have no ready-made field in ThemeData; keeping them as constants or in a ThemeExtension like the one below stops numbers from scattering randomly through the code.
Accessibility: High Contrast
ColorScheme.fromSeed lets you adjust contrast with the contrastLevel parameter: 0 is the default, and the medium and high contrast levels from the Material guidelines correspond to 0.5 and 1.0. MaterialApp's highContrastTheme and highContrastDarkTheme fields kick in when the user turns on the increased contrast setting in the operating system (on iOS, for example). Combining the two is easy: add a contrast parameter to the same buildTheme function and generate the high-contrast themes from there as well.
Your Own Design Values: ThemeExtension
ColorScheme has no roles like "success" or "warning". Instead of scattering such values as hard-coded colors, you can add them to the theme with ThemeExtension, so they also change with the light and dark themes:
@immutable
class StatusColors extends ThemeExtension<StatusColors> {
const StatusColors({required this.success, required this.warning});
final Color success;
final Color warning;
static const light = StatusColors(
success: Color(0xFF2E7D32),
warning: Color(0xFF8D6E00),
);
static const dark = StatusColors(
success: Color(0xFF81C784),
warning: Color(0xFFFFD54F),
);
@override
StatusColors copyWith({Color? success, Color? warning}) {
return StatusColors(
success: success ?? this.success,
warning: warning ?? this.warning,
);
}
@override
StatusColors lerp(StatusColors? other, double t) {
if (other == null) return this;
return StatusColors(
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
);
}
}It's added to the theme as extensions: [StatusColors.light] (StatusColors.dark in the dark theme) and read in a widget with Theme.of(context).extension<StatusColors>()!.success. The lerp method lets the colors transition smoothly while the theme changes.
Mini Scenario: Theme Picker with a Preview
Now let's put the pieces together: we add the extensions line to the buildTheme function above and build a screen where the user picks System, Light or Dark and sees the result immediately.
// The line added to the ThemeData in buildTheme:
// extensions: [
// brightness == Brightness.light ? StatusColors.light : StatusColors.dark,
// ],
void main() => runApp(const ThemeDemoApp());
class ThemeDemoApp extends StatefulWidget {
const ThemeDemoApp({super.key});
@override
State<ThemeDemoApp> createState() => _ThemeDemoAppState();
}
class _ThemeDemoAppState extends State<ThemeDemoApp> {
final _themeMode = ValueNotifier(ThemeMode.system);
@override
void dispose() {
_themeMode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeMode>(
valueListenable: _themeMode,
builder: (context, mode, child) => MaterialApp(
theme: buildTheme(Brightness.light),
darkTheme: buildTheme(Brightness.dark),
themeMode: mode,
home: ThemePreviewPage(themeMode: _themeMode),
),
);
}
}
class ThemePreviewPage extends StatelessWidget {
const ThemePreviewPage({super.key, required this.themeMode});
final ValueNotifier<ThemeMode> themeMode;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final text = Theme.of(context).textTheme;
final status = Theme.of(context).extension<StatusColors>()!;
return Scaffold(
appBar: AppBar(title: const Text('Appearance')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Theme', style: text.titleMedium),
const SizedBox(height: 8),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(value: ThemeMode.system, label: Text('System')),
ButtonSegment(value: ThemeMode.light, label: Text('Light')),
ButtonSegment(value: ThemeMode.dark, label: Text('Dark')),
],
selected: {themeMode.value},
onSelectionChanged: (selection) => themeMode.value = selection.first,
),
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Weekly summary', style: text.headlineSmall),
const SizedBox(height: 8),
Text(
'You studied 5 days this week.',
style: text.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text('Goal reached', style: TextStyle(color: status.success)),
],
),
),
),
const SizedBox(height: 16),
const TextField(decoration: InputDecoration(labelText: 'Add a note')),
const SizedBox(height: 16),
FilledButton(onPressed: () {}, child: const Text('Save')),
],
),
);
}
}Not a single widget on this screen has a color or corner radius written on it. The card, text field and button get their look from the component themes in buildTheme, the text from textTheme, and "Goal reached" from StatusColors. When the user taps Dark, the ValueNotifier changes, MaterialApp rebuilds and everything switches to the dark theme. MaterialApp animates briefly between the two themes; StatusColors.lerp produces the in-between colors during that transition.
In this example the choice is lost when the app closes. To make it persistent, you use a controller that writes the value to disk instead of a ValueNotifier; I show the complete version of this structure that stores the ThemeMode in local data storage with SharedPreferences. If several screens can change the theme preference, keeping it in a ChangeNotifier with Provider is the same idea taken further.
Common Mistakes
1. Using hard-coded colors
Symptom: a screen that looks great in the light theme is full of glaring white boxes and unreadable text in the dark theme. The cause is fixed values like Colors.white and Colors.black. When you use surface or a surfaceContainer tone for backgrounds and onSurface for text, both themes look right. The dark theme bug in the Card post has the same root. The same goes for icons: instead of writing color: Colors.black87 on an Icon, leave the color out and the icon takes the right color from the theme of the component it sits in; one color in the AppBar, another inside a card.
2. Trying to set colors with primarySwatch
ThemeData(primarySwatch: Colors.green), common in older examples, doesn't change the color scheme in Material 3; buttons stay in the default purple tones. In Material 3, colors are set through colorScheme (or its shortcut colorSchemeSeed).
3. brightness and colorScheme not matching
ThemeData(brightness: Brightness.dark, colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo)) throws this assertion error:
ThemeData.brightness does not match ColorScheme.brightness.fromSeed generates a light scheme by default. For a dark theme, pass brightness: Brightness.dark to fromSeed; you don't need to set brightness on ThemeData as well.
4. Calling Theme.of(context) with a context above MaterialApp
If you call Theme.of(context) in the build method that returns MaterialApp, that context isn't inside the MaterialApp yet, so you get the default theme. Move the code that needs the theme into a widget under home, or get a new context with a Builder.
5. Deprecated names
In ColorScheme, background, onBackground and surfaceVariant are deprecated; surface, onSurface and surfaceContainerHighest replace them. Use withValues(alpha: ...) instead of withOpacity, and the Material 3 names instead of the old TextTheme names. Don't ignore analyzer warnings; most of them tell you the new name directly.
Frequently Asked Questions
Why does a white screen flash briefly when the app opens in dark mode?
That screen is the native launch screen drawn before Flutter starts, and ThemeData doesn't affect it. It's defined by the launch_background drawable on Android and LaunchScreen.storyboard on iOS; for dark mode you can add a separate drawable in a drawable-night folder on Android, or leave the job to a package like flutter_native_splash. If you store the user's theme choice on disk, reading it before runApp also prevents the wrong theme from showing on the first frame.
Does the app update automatically when the system theme changes?
Yes, with themeMode: ThemeMode.system. MaterialApp listens to the platform brightness and switches to darkTheme when the user puts the device in dark mode; you don't need to listen for anything yourself.
Can different parts of the same app use different colors?
Yes. Wrapping the relevant subtree in Theme(data: Theme.of(context).copyWith(...), child: ...) is enough. Widgets inside see the new theme through Theme.of(context), and nothing outside is affected. Showing destructive action buttons with a red scheme is the typical use.
Related Posts
Flutter: BottomSheet and showModalBottomSheet
Flutter's showModalBottomSheet: returning values, isScrollControlled, useSafeArea, showDragHandle, keyboard insets, DraggableScrollableSheet, persistent sheets.
Flutter: BottomNavigationBar vs NavigationBar
Migrating from BottomNavigationBar to Material 3 NavigationBar: property mapping, labelBehavior, a decision guide and an adaptive shell example.
Flutter: DropdownButton Usage and Features
Flutter DropdownButton and DropdownButtonFormField, the Material 3 DropdownMenu, a dependent dropdown scenario and the value assertion fix.