Dark mode went from a novelty to a user expectation somewhere around 2021. By 2026, it's table stakes — 82% of smartphone users have dark mode enabled at least part of the time (Android's own usage data backs this up). If your Flutter app doesn't support it, you're shipping a product that looks broken on most devices at night and drains battery faster on OLED screens during the day.
The good news: Flutter's theming system makes dark mode almost trivial to implement. The bad news: "almost"
is where most developers get stuck. They set a darkTheme on MaterialApp, see it
mostly works, then spend weeks hunting down hardcoded colors buried in random widgets. This guide covers the
full implementation — from initial setup to the edge cases that only surface in production.
Why Dark Mode Isn't Optional Anymore
Three reasons, in order of what your users actually care about:
- Eye comfort. Bright white screens at midnight cause physical discomfort. Users with astigmatism (roughly 33% of the population) find high-contrast dark text on white backgrounds harder to read in low light. Dark mode reduces perceived brightness by 60-70%.
- Battery life. On OLED and AMOLED screens (which most flagship phones use now), dark pixels are literally turned off. A true-black dark theme can reduce screen power consumption by up to 63% compared to a white theme. That's real battery life users can feel.
- It looks good. Dark themes make colors pop, give apps a premium feel, and reduce visual clutter. There's a reason every major app — Instagram, Twitter, YouTube, WhatsApp — invested heavily in their dark themes.
Basic Dark Mode Setup with ThemeData
Flutter's MaterialApp accepts three theme-related properties. Here's the minimum setup:
MaterialApp(
title: 'My App',
theme: ThemeData(
brightness: Brightness.light,
colorSchemeSeed: const Color(0xFF6750A4),
useMaterial3: true,
),
darkTheme: ThemeData(
brightness: Brightness.dark,
colorSchemeSeed: const Color(0xFF6750A4),
useMaterial3: true,
),
themeMode: ThemeMode.system, // follows device setting
home: const HomeScreen(),
);
With ThemeMode.system, your app automatically switches between light and dark based on the
device's system setting. No toggle needed. The colorSchemeSeed generates a full color palette —
light and dark variants — from a single seed color. Flutter handles the contrast ratios, surface tones, and
elevation overlays automatically.
This alone gets you 80% of the way there. Most Material widgets — AppBar, Card,
BottomNavigationBar, TextField, ElevatedButton — read their colors
from the current theme and adapt without any changes to your widget code.
Material 3 Color Schemes
If you need more control than colorSchemeSeed provides, define full ColorScheme
objects. This is what I do for production apps where the designer hands over specific Figma colors:
class AppTheme {
static const _seedColor = Color(0xFF6750A4);
static final light = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: _seedColor,
brightness: Brightness.light,
),
textTheme: _textTheme,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: const CardThemeData(
elevation: 1,
margin: EdgeInsets.all(8),
),
);
static final dark = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: _seedColor,
brightness: Brightness.dark,
),
textTheme: _textTheme,
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: const CardThemeData(
elevation: 1,
margin: EdgeInsets.all(8),
),
);
static const _textTheme = TextTheme(
headlineLarge: TextStyle(fontWeight: FontWeight.w700),
titleMedium: TextStyle(fontWeight: FontWeight.w600),
bodyLarge: TextStyle(height: 1.6),
);
}
Then in your MaterialApp:
MaterialApp(
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.system,
// ...
);
ColorScheme.fromSeed vs Custom ColorScheme
ColorScheme.fromSeed() generates 30+ color tokens from one seed color using the HCT
(Hue-Chroma-Tone) color space. It guarantees WCAG AA contrast ratios between surface and on-surface
colors. Only override individual colors if your brand guidelines demand a specific hex value — and even
then, verify the contrast ratio manually. I've seen designers hand over dark-on-dark color pairs that
fail accessibility checks.
Building a Theme Provider
To let users toggle between light, dark, and system themes, you need state management. Here's a clean
approach using ChangeNotifier:
class ThemeProvider extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system;
ThemeMode get themeMode => _themeMode;
bool get isDark => _themeMode == ThemeMode.dark;
bool get isSystem => _themeMode == ThemeMode.system;
void setThemeMode(ThemeMode mode) {
_themeMode = mode;
notifyListeners();
}
void toggleTheme() {
_themeMode = _themeMode == ThemeMode.light
? ThemeMode.dark
: ThemeMode.light;
notifyListeners();
}
}
Wire it up at the app root:
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => ThemeProvider(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
return MaterialApp(
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: themeProvider.themeMode,
home: const HomeScreen(),
);
}
}
Now any widget in your tree can read or change the theme:
// In a settings screen
IconButton(
icon: Icon(
context.watch<ThemeProvider>().isDark
? Icons.light_mode
: Icons.dark_mode,
),
onPressed: () => context.read<ThemeProvider>().toggleTheme(),
);
Persisting Theme Preference
Users expect their theme choice to survive app restarts. Without persistence, the app resets to system
default every launch — which defeats the purpose of having a toggle. Use SharedPreferences:
class ThemeProvider extends ChangeNotifier {
static const _key = 'theme_mode';
ThemeMode _themeMode = ThemeMode.system;
ThemeMode get themeMode => _themeMode;
ThemeProvider() {
_loadTheme();
}
Future<void> _loadTheme() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getString(_key);
if (value != null) {
_themeMode = ThemeMode.values.firstWhere(
(e) => e.name == value,
orElse: () => ThemeMode.system,
);
notifyListeners();
}
}
Future<void> setThemeMode(ThemeMode mode) async {
_themeMode = mode;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, mode.name);
}
}
The _loadTheme call in the constructor fires asynchronously. The app renders with
ThemeMode.system for the first frame (usually 16ms), then switches to the stored preference.
This is imperceptible — users won't see a flash. If you're paranoid about it, load the preference in
main() before runApp and pass it into the provider's constructor.
Detecting System Theme Changes
When themeMode is ThemeMode.system, Flutter automatically reacts to system theme
changes. But sometimes you need to know the current system brightness for conditional logic — like choosing
an image variant or adjusting a gradient.
// Check current effective brightness
final isDark = Theme.of(context).brightness == Brightness.dark;
// Check system brightness directly (ignores user override)
final systemIsDark = MediaQuery.platformBrightnessOf(context) == Brightness.dark;
// React to system changes with WidgetsBindingObserver
class _MyWidgetState extends State<MyWidget> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangePlatformBrightness() {
// System theme just changed — do something
setState(() {});
}
}
The Theme.of(context).brightness check is what you'll use 95% of the time. It reflects the
actual theme being rendered, whether that's from the user's manual choice or the system setting. Use
MediaQuery.platformBrightnessOf(context) only when you specifically need the system's
preference regardless of the user's override in your app.
Theming Custom Components
The moment you build a custom widget with hardcoded colors, dark mode breaks. Here's the rule: never use
Color(0xFF...) directly in widget code. Always pull from the theme.
// BAD - breaks in dark mode
Container(
color: Colors.white,
child: Text('Hello', style: TextStyle(color: Colors.black)),
)
// GOOD - adapts to theme
Container(
color: Theme.of(context).colorScheme.surface,
child: Text(
'Hello',
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
)
For custom components that need theme-specific styling, use ThemeExtension:
class CustomCardTheme extends ThemeExtension<CustomCardTheme> {
final Color? gradient1;
final Color? gradient2;
final Color? borderColor;
const CustomCardTheme({this.gradient1, this.gradient2, this.borderColor});
@override
CustomCardTheme copyWith({Color? gradient1, Color? gradient2, Color? borderColor}) {
return CustomCardTheme(
gradient1: gradient1 ?? this.gradient1,
gradient2: gradient2 ?? this.gradient2,
borderColor: borderColor ?? this.borderColor,
);
}
@override
CustomCardTheme lerp(covariant ThemeExtension<CustomCardTheme>? other, double t) {
if (other is! CustomCardTheme) return this;
return CustomCardTheme(
gradient1: Color.lerp(gradient1, other.gradient1, t),
gradient2: Color.lerp(gradient2, other.gradient2, t),
borderColor: Color.lerp(borderColor, other.borderColor, t),
);
}
}
Register it in both themes:
ThemeData(
extensions: [
const CustomCardTheme(
gradient1: Color(0xFFE3F2FD),
gradient2: Color(0xFFBBDEFB),
borderColor: Color(0xFF90CAF9),
),
],
// ... rest of theme
);
// Access in widgets
final cardTheme = Theme.of(context).extension<CustomCardTheme>()!;
Theme extensions give you type-safe custom theme tokens that animate during theme transitions. They're the official way to extend Flutter's theming system — much cleaner than maintaining separate color maps.
Handling Images and Assets
Images are the most overlooked dark mode problem. A logo with a transparent background looks great on white, but disappears on dark surfaces. Here's how I handle it:
// For logos and illustrations with transparent backgrounds
Image.asset(
Theme.of(context).brightness == Brightness.dark
? 'assets/images/logo_dark.png'
: 'assets/images/logo_light.png',
)
// For photos that work on both but need brightness adjustment
ColorFiltered(
colorFilter: ColorFilter.mode(
Theme.of(context).brightness == Brightness.dark
? Colors.black.withValues(alpha: 0.1)
: Colors.transparent,
BlendMode.darken,
),
child: Image.asset('assets/images/hero.jpg'),
)
For SVG icons, don't use separate light/dark variants — use colorFilter on the SVG widget or
reference ColorScheme colors directly. Most SVG packages (flutter_svg,
jovial_svg) support color overrides.
Status Bar and Navigation Bar
Nothing ruins a polished dark mode like a dark status bar with dark icons. The fix belongs in your theme definition, not scattered across individual screens:
ThemeData(
appBarTheme: AppBarTheme(
systemOverlayStyle: SystemUiOverlayStyle(
// Status bar
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light, // Android: light icons
statusBarBrightness: Brightness.dark, // iOS: light icons
// Navigation bar
systemNavigationBarColor: const Color(0xFF1C1B1F),
systemNavigationBarIconBrightness: Brightness.light,
),
),
);
Set this in your dark theme only. The light theme should use the inverse: Brightness.dark for
icon brightness (dark icons on light background). By putting it in AppBarTheme, every screen
with an AppBar gets the correct overlay style automatically. For screens without an
AppBar, use AnnotatedRegion<SystemUiOverlayStyle> instead.
Testing Dark Mode
Test both themes during widget testing. I've seen teams write 200+ widget tests, all running against the light theme only, then wonder why dark mode has visual bugs:
// Helper to test both themes
void testBothThemes(String description, WidgetTesterCallback callback) {
testWidgets('$description (light)', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light,
home: const TestableWidget(),
),
);
await callback(tester);
});
testWidgets('$description (dark)', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.dark,
home: const TestableWidget(),
),
);
await callback(tester);
});
}
// Usage
testBothThemes('displays user profile correctly', (tester) async {
expect(find.text('John Doe'), findsOneWidget);
expect(find.byType(CircleAvatar), findsOneWidget);
});
For visual regression testing, tools like golden_toolkit let you capture screenshots of both
themes and diff them against baselines. This catches subtle issues like text becoming invisible against its
background.
Common Mistakes
I've done all of these. Learn from my pain:
- Hardcoded colors in widgets. Search your codebase for
Color(0xandColors.in widget files. Every instance is a potential dark mode bug. Replace them withTheme.of(context).colorSchemereferences. - Using
Colors.blackandColors.whitefor text. UsecolorScheme.onSurfaceinstead. Black text on a #121212 dark surface is invisible. - Forgetting
Scaffoldbackground. If you're settingbackgroundColoronScaffoldexplicitly, you're overriding the theme. Remove it — the theme handles it. - Ignoring elevation overlays. In Material 3 dark theme, elevated surfaces get a slight tint (not a shadow). If you override surface colors manually, you lose this effect and cards blend into the background.
- Testing only on white-background screens. Navigate your entire app in dark mode. Dialogs, bottom sheets, snackbars, date pickers — they all need to look right. The built-in Material widgets work, but custom overlays often don't.
- Not handling splash screen. Your Flutter theme loads after the native splash. If your
splash is white and the app is dark, there's a jarring flash. Match your
flutter_native_splashconfiguration to your dark theme, or use theandroid12configuration for adaptive splash screens.
Related Guides
- BLoC vs Riverpod: Which State Management to Choose in 2026
- Advanced Custom Widget Composition Patterns
- Flutter Testing Strategy: Unit, Widget, and Integration Tests
- Flutter App Security: A Complete Guide
- Best Flutter State Management 2026
- Flutter Performance Optimization: A Complete Guide
- Mastering Flutter Layouts: Flex, Stack, and Custom Layouts
- Flutter Clean Architecture: A Practical Guide
- Firebase Authentication in Flutter: Complete Guide 2026
- Flutter Monetization: Ads, Subscriptions, and In-App Purchases
🔗 Official Resources
Frequently Asked Questions
Does Flutter support dark mode out of the box?
Yes. Flutter's MaterialApp has built-in dark mode support through the
theme, darkTheme, and themeMode properties. Set
theme to your light ThemeData, darkTheme to your dark
ThemeData, and themeMode to ThemeMode.system to automatically
follow the device's dark mode setting. Most Material widgets adapt their colors automatically — no
per-widget changes needed.
Should I use Material 3 ColorScheme or define colors manually?
Use ColorScheme. Manual color definitions (primaryColor,
accentColor, etc.) are legacy patterns from Material 2. In Material 3,
ColorScheme.fromSeed() generates a complete, accessible color palette from a single
seed color. It handles contrast ratios, surface tones, and color harmony automatically. You get 30+
coordinated colors instead of picking each one yourself.
How do I persist the user's theme preference?
Use SharedPreferences or Hive to store the user's choice (light, dark, or system). Load
it at app startup before building the widget tree. SharedPreferences is the simpler
option — store a string like 'light', 'dark', or 'system' and read it in your theme provider's
constructor. Hive is faster for frequent reads but overkill for a single preference value.
How do I handle images and icons in dark mode?
For icons, use Theme.of(context).iconTheme.color or ColorScheme colors
instead of hardcoded colors. For images, you have three options: (1) use the same image if it works
on both backgrounds, (2) provide separate light/dark variants and switch with
Theme.of(context).brightness, or (3) apply a ColorFiltered widget to
adjust brightness. Most apps use option 1 for photos and option 2 for illustrations or logos with
transparent backgrounds.
Can I add a theme toggle animation?
Yes. Wrap your MaterialApp in an AnimatedTheme widget, or use the
themeAnimationDuration and themeAnimationCurve properties on
MaterialApp (available since Flutter 3.10). For a smoother effect, set
themeAnimationDuration to Duration(milliseconds: 300) and
themeAnimationCurve to Curves.easeInOut. For advanced circular-reveal
animations, use the animated_theme_switcher package.
What about status bar and navigation bar colors in dark mode?
Use SystemChrome.setSystemUIOverlayStyle() or the AppBarTheme's
systemOverlayStyle property. In dark mode, set statusBarBrightness to
Brightness.dark (iOS) and statusBarIconBrightness to
Brightness.light (Android) for white status bar icons. The cleanest approach is setting
systemOverlayStyle in your AppBarTheme so it automatically switches with
the theme — no manual SystemChrome calls needed.
How do I test dark mode without changing device settings?
Set themeMode to ThemeMode.dark temporarily in your
MaterialApp to force dark mode during development. In widget tests, wrap your widget
with a MaterialApp that uses the dark theme. For integration tests, you can change the
platform brightness with
binding.platformDispatcher.platformBrightnessTestValue = Brightness.dark. The Flutter
DevTools also let you toggle dark mode from the inspector panel.