Dark Mode & Dynamic Themes
Why Dark Mode Matters
Dark mode isn’t just an aesthetic preference -- it’s a critical feature that improves user experience, reduces eye strain in low-light environments, and saves battery on OLED screens. Flutter provides first-class support for dark mode through MaterialApp’s darkTheme parameter and the ThemeMode enum. In this lesson, you’ll learn how to implement a complete dark mode system with automatic detection, manual toggling, and persistent preferences.
ThemeMode: The Three States
Flutter’s ThemeMode enum determines which theme the app uses. Understanding these three modes is the foundation of dark mode implementation.
ThemeMode Options
MaterialApp(
// Light theme -- used when ThemeMode.light or ThemeMode.system (light)
theme: AppTheme.lightTheme,
// Dark theme -- used when ThemeMode.dark or ThemeMode.system (dark)
darkTheme: AppTheme.darkTheme,
// Controls which theme is active
themeMode: ThemeMode.system, // Default: follows system setting
// ThemeMode.light -- Always use light theme
// ThemeMode.dark -- Always use dark theme
// ThemeMode.system -- Follow the OS dark/light mode setting
)
Setting Up Light and Dark Themes
The key to good dark mode is defining both themes with intentional design, not just inverting colors. Dark themes need careful consideration of elevation, contrast, and surface colors.
Complete Light and Dark Theme Definitions
class AppTheme {
static const _seedColor = Color(0xFF1E88E5);
static ThemeData get lightTheme {
final colorScheme = ColorScheme.fromSeed(
seedColor: _seedColor,
brightness: Brightness.light,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
appBarTheme: AppBarTheme(
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
elevation: 0,
scrolledUnderElevation: 1,
),
cardTheme: CardTheme(
elevation: 1,
color: colorScheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
dividerTheme: DividerThemeData(
color: colorScheme.outlineVariant,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
static ThemeData get darkTheme {
final colorScheme = ColorScheme.fromSeed(
seedColor: _seedColor,
brightness: Brightness.dark,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
appBarTheme: AppBarTheme(
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
elevation: 0,
scrolledUnderElevation: 1,
),
cardTheme: CardTheme(
elevation: 1,
color: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
dividerTheme: DividerThemeData(
color: colorScheme.outlineVariant,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
);
}
}
surfaceContainerLowest, surfaceContainerLow, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest.Detecting System Brightness
Sometimes you need to know the current brightness outside of the theme system -- for example, to load different assets or apply conditional logic.
Reading System Brightness
class AdaptiveScreen extends StatelessWidget {
const AdaptiveScreen({super.key});
@override
Widget build(BuildContext context) {
// Method 1: Check the current theme brightness
final isDark = Theme.of(context).brightness == Brightness.dark;
// Method 2: Check the platform's brightness setting directly
final platformBrightness = MediaQuery.platformBrightnessOf(context);
final isSystemDark = platformBrightness == Brightness.dark;
// Method 3: Check ColorScheme brightness
final isColorSchemeDark =
Theme.of(context).colorScheme.brightness == Brightness.dark;
return Column(
children: [
// Use different images based on theme
Image.asset(
isDark ? 'assets/logo_dark.png' : 'assets/logo_light.png',
),
// Adaptive shadow
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
boxShadow: isDark
? [] // No shadows in dark mode
: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Text('Adaptive Container'),
),
],
);
}
}
Runtime Theme Switching
To allow users to toggle between light, dark, and system modes at runtime, you need a state management solution. Here’s a clean implementation using a ValueNotifier:
Theme Controller with ValueNotifier
// lib/theme/theme_controller.dart
class ThemeController extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system;
ThemeMode get themeMode => _themeMode;
bool get isDarkMode => _themeMode == ThemeMode.dark;
bool get isLightMode => _themeMode == ThemeMode.light;
bool get isSystemMode => _themeMode == ThemeMode.system;
void setThemeMode(ThemeMode mode) {
if (_themeMode != mode) {
_themeMode = mode;
notifyListeners();
}
}
void toggleTheme() {
setThemeMode(
_themeMode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark,
);
}
}
// lib/main.dart
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final _themeController = ThemeController();
@override
void dispose() {
_themeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _themeController,
builder: (context, child) {
return MaterialApp(
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: _themeController.themeMode,
home: HomeScreen(themeController: _themeController),
);
},
);
}
}
Theme Toggle UI
class ThemeSettingsSection extends StatelessWidget {
final ThemeController themeController;
const ThemeSettingsSection({required this.themeController, super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Appearance',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// Segmented button for theme selection
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode),
label: Text('Light'),
),
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.settings_brightness),
label: Text('System'),
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode),
label: Text('Dark'),
),
],
selected: {themeController.themeMode},
onSelectionChanged: (selection) {
themeController.setThemeMode(selection.first);
},
),
],
);
}
}
Persisting Theme Preference
Users expect their theme choice to be remembered across app restarts. Use SharedPreferences to persist the selection.
Persistent Theme Controller
import 'package:shared_preferences/shared_preferences.dart';
class PersistentThemeController extends ChangeNotifier {
static const _key = 'theme_mode';
final SharedPreferences _prefs;
ThemeMode _themeMode;
PersistentThemeController(this._prefs)
: _themeMode = _themeModeFromString(
_prefs.getString(_key) ?? 'system',
);
ThemeMode get themeMode => _themeMode;
Future<void> setThemeMode(ThemeMode mode) async {
if (_themeMode != mode) {
_themeMode = mode;
await _prefs.setString(_key, _themeModeToString(mode));
notifyListeners();
}
}
static ThemeMode _themeModeFromString(String value) {
return switch (value) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
}
static String _themeModeToString(ThemeMode mode) {
return switch (mode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
ThemeMode.system => 'system',
};
}
}
// Initialize in main()
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final themeController = PersistentThemeController(prefs);
runApp(MyApp(themeController: themeController));
}
Adaptive Colors for Both Modes
Sometimes you need colors that aren’t part of the Material color scheme but still need to adapt to light/dark mode. Use theme extensions for this:
Adaptive Custom Colors
class AdaptiveColors extends ThemeExtension<AdaptiveColors> {
final Color? codeBackground;
final Color? codeText;
final Color? linkColor;
final Color? highlightColor;
final Color? shadowColor;
final Color? shimmerBase;
final Color? shimmerHighlight;
const AdaptiveColors({
this.codeBackground,
this.codeText,
this.linkColor,
this.highlightColor,
this.shadowColor,
this.shimmerBase,
this.shimmerHighlight,
});
// Light mode colors
static const light = AdaptiveColors(
codeBackground: Color(0xFFF5F5F5),
codeText: Color(0xFF37474F),
linkColor: Color(0xFF1565C0),
highlightColor: Color(0xFFFFF9C4),
shadowColor: Color(0x1A000000),
shimmerBase: Color(0xFFE0E0E0),
shimmerHighlight: Color(0xFFF5F5F5),
);
// Dark mode colors
static const dark = AdaptiveColors(
codeBackground: Color(0xFF1E1E2E),
codeText: Color(0xFFCDD6F4),
linkColor: Color(0xFF82B1FF),
highlightColor: Color(0xFF3E2723),
shadowColor: Color(0x40000000),
shimmerBase: Color(0xFF303030),
shimmerHighlight: Color(0xFF424242),
);
@override
AdaptiveColors copyWith({
Color? codeBackground,
Color? codeText,
Color? linkColor,
Color? highlightColor,
Color? shadowColor,
Color? shimmerBase,
Color? shimmerHighlight,
}) {
return AdaptiveColors(
codeBackground: codeBackground ?? this.codeBackground,
codeText: codeText ?? this.codeText,
linkColor: linkColor ?? this.linkColor,
highlightColor: highlightColor ?? this.highlightColor,
shadowColor: shadowColor ?? this.shadowColor,
shimmerBase: shimmerBase ?? this.shimmerBase,
shimmerHighlight: shimmerHighlight ?? this.shimmerHighlight,
);
}
@override
AdaptiveColors lerp(AdaptiveColors? other, double t) {
if (other is! AdaptiveColors) return this;
return AdaptiveColors(
codeBackground: Color.lerp(codeBackground, other.codeBackground, t),
codeText: Color.lerp(codeText, other.codeText, t),
linkColor: Color.lerp(linkColor, other.linkColor, t),
highlightColor: Color.lerp(highlightColor, other.highlightColor, t),
shadowColor: Color.lerp(shadowColor, other.shadowColor, t),
shimmerBase: Color.lerp(shimmerBase, other.shimmerBase, t),
shimmerHighlight: Color.lerp(shimmerHighlight, other.shimmerHighlight, t),
);
}
}
// Register in themes
static ThemeData get lightTheme => ThemeData(
extensions: const [AdaptiveColors.light],
// ...
);
static ThemeData get darkTheme => ThemeData(
extensions: const [AdaptiveColors.dark],
// ...
);
// Usage
Widget build(BuildContext context) {
final adaptive = Theme.of(context).extension<AdaptiveColors>()!;
return Container(
color: adaptive.codeBackground,
child: Text(
'final x = 42;',
style: TextStyle(
color: adaptive.codeText,
fontFamily: 'monospace',
),
),
);
}
Animated Theme Transitions
By default, Flutter animates theme changes. You can customize the animation duration and even use AnimatedTheme for more control:
Custom Theme Animation
// MaterialApp automatically animates between themes
// Customize the duration:
MaterialApp(
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: currentMode,
themeAnimationDuration: const Duration(milliseconds: 400),
themeAnimationCurve: Curves.easeInOut,
)
// For manual animation within a subtree:
class AnimatedThemeExample extends StatelessWidget {
final bool isDark;
const AnimatedThemeExample({required this.isDark, super.key});
@override
Widget build(BuildContext context) {
return AnimatedTheme(
data: isDark ? AppTheme.darkTheme : AppTheme.lightTheme,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOutCubic,
child: Builder(
builder: (context) {
final theme = Theme.of(context);
return Container(
color: theme.colorScheme.surface,
child: Text(
'This animates smoothly!',
style: TextStyle(color: theme.colorScheme.onSurface),
),
);
},
),
);
}
}
Practical Example: Complete System-Aware App
Let’s build a complete example that ties everything together -- system detection, manual toggle, persistent preference, and adaptive colors:
Complete Dark Mode Implementation
// lib/theme/app_theme.dart
import 'package:flutter/material.dart';
class AppTheme {
static const _seed = Color(0xFF6750A4);
static ThemeData get light => _build(
ColorScheme.fromSeed(seedColor: _seed, brightness: Brightness.light),
AdaptiveColors.light,
);
static ThemeData get dark => _build(
ColorScheme.fromSeed(seedColor: _seed, brightness: Brightness.dark),
AdaptiveColors.dark,
);
static ThemeData _build(ColorScheme scheme, AdaptiveColors adaptive) {
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: scheme.surface,
appBarTheme: AppBarTheme(
backgroundColor: scheme.surface,
foregroundColor: scheme.onSurface,
elevation: 0,
),
cardTheme: CardTheme(
elevation: scheme.brightness == Brightness.dark ? 0 : 1,
color: scheme.brightness == Brightness.dark
? scheme.surfaceContainerHigh
: scheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: scheme.brightness == Brightness.dark
? BorderSide(color: scheme.outlineVariant.withOpacity(0.3))
: BorderSide.none,
),
),
extensions: [adaptive],
);
}
}
// lib/main.dart
import 'package:shared_preferences/shared_preferences.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final themeCtrl = PersistentThemeController(prefs);
runApp(MyApp(themeController: themeCtrl));
}
class MyApp extends StatelessWidget {
final PersistentThemeController themeController;
const MyApp({required this.themeController, super.key});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: themeController,
builder: (context, _) {
return MaterialApp(
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: themeController.themeMode,
themeAnimationDuration: const Duration(milliseconds: 300),
home: SettingsScreen(themeController: themeController),
);
},
);
}
}
// lib/screens/settings_screen.dart
class SettingsScreen extends StatelessWidget {
final PersistentThemeController themeController;
const SettingsScreen({required this.themeController, super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final adaptive = theme.extension<AdaptiveColors>()!;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Theme mode selector
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Theme', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode),
label: Text('Light'),
),
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.auto_mode),
label: Text('Auto'),
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode),
label: Text('Dark'),
),
],
selected: {themeController.themeMode},
onSelectionChanged: (s) =>
themeController.setThemeMode(s.first),
),
],
),
),
),
const SizedBox(height: 16),
// Preview card showing adaptive colors
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Preview', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: adaptive.codeBackground,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'void main() => runApp(MyApp());',
style: TextStyle(
color: adaptive.codeText,
fontFamily: 'monospace',
fontSize: 14,
),
),
),
],
),
),
),
],
),
);
}
}
Colors.white or Colors.black for backgrounds and text. Always use colorScheme.surface, colorScheme.onSurface, etc. Hardcoded colors will look wrong in the opposite theme mode -- white text on a white background in light mode, or dark icons invisible on dark backgrounds.Custom Color Palettes Per Mode
For apps that need significantly different color palettes in each mode (not just light/dark variants of the same seed), you can define completely separate color schemes:
Distinct Palettes Per Mode
class AppTheme {
// Light mode: Warm blue-grey palette
static final _lightScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF546E7A),
brightness: Brightness.light,
).copyWith(
primary: const Color(0xFF37474F),
secondary: const Color(0xFFFF8A65),
tertiary: const Color(0xFF66BB6A),
);
// Dark mode: Deep purple-blue palette (different mood)
static final _darkScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF7C4DFF),
brightness: Brightness.dark,
).copyWith(
primary: const Color(0xFFB388FF),
secondary: const Color(0xFFFF8A80),
tertiary: const Color(0xFF69F0AE),
);
static ThemeData get lightTheme => ThemeData(
useMaterial3: true,
colorScheme: _lightScheme,
extensions: const [AdaptiveColors.light],
);
static ThemeData get darkTheme => ThemeData(
useMaterial3: true,
colorScheme: _darkScheme,
extensions: const [AdaptiveColors.dark],
);
}
Summary
- ThemeMode.system follows the OS setting,
.lightforces light,.darkforces dark. - Always define both
themeanddarkThemein MaterialApp for proper system-aware behavior. - Use
MediaQuery.platformBrightnessOf(context)to detect system brightness directly. - Implement a ThemeController with ChangeNotifier for runtime theme switching.
- Persist theme preference with SharedPreferences so users don’t have to set it every launch.
- Use ThemeExtension for adaptive custom colors that differ between light and dark modes.
- In dark mode, express elevation through lighter surface tones, not shadows.
- Never hardcode Colors.white/black -- always use colorScheme semantic colors.
- Flutter automatically animates theme transitions; customize with
themeAnimationDuration.