Flutter Dark Mode: The Complete Implementation Guide for 2026
Dark mode is no longer optional. Users expect it. App stores reward it. And if you’ve ever tried to bolt it onto an existing Flutter app at the last minute, you know how painful that can be.
This guide walks you through implementing Flutter dark mode the right way — from project setup to dynamic theme switching — with production-quality code you can drop straight into your app.
Whether you’re starting fresh or retrofitting an existing app, this guide covers everything: ThemeData, ThemeMode, persistent user preferences, and common pitfalls that trip up even experienced Flutter developers.
The Problem: Why Dark Mode Breaks Flutter Apps
Most Flutter developers add dark mode as an afterthought. They hardcode colors (Colors.white, Colors.black), use Theme.of(context) inconsistently, or forget to handle dynamic theme switching entirely.
The result? An app that looks fine in light mode but has white text on white backgrounds, invisible icons, or a jarring flash of the wrong theme on startup.
Here’s what developers typically do wrong:
- Hardcoding color values directly in widgets instead of referencing the theme
- Ignoring
ColorSchemeand relying on deprecatedprimaryColorproperties - Not persisting the user’s preference across app restarts
- Mixing light and dark colors accidentally because widget trees are deep
- Forgetting system-level dark mode — the user’s OS setting should be respected by default
Solution Overview
The cleanest approach uses Flutter’s built-in theming system with three layers:
ThemeData— Define your light and dark themes usingColorScheme.fromSeed()orColorSchemedirectlyThemeMode— Control which theme is active (light,dark, orsystem)SharedPreferences— Persist the user's choice across sessionsChangeNotifier/Provider— Manage and propagate theme state through the widget tree
This approach is clean, scalable, and follows Flutter’s recommended architecture. Let’s build it.
Step 1: Project Setup
Make sure your Flutter project is on a recent SDK version. This guide uses Flutter 3.x with Material 3 enabled.
Open pubspec.yaml and add the required packages:
dependencies:
flutter:
sdk: flutter
provider: ^6.1.2
shared_preferences: ^2.3.1Run:
flutter pub getYour folder structure should look like this:
lib/
├── main.dart
├── theme/
│ ├── app_theme.dart
│ └── theme_provider.dart
├── screens/
│ └── home_screen.dart
└── widgets/
└── theme_toggle_button.dartVisual note: Here you should show a screenshot of the folder structure in VS Code or Android Studio, with the
theme/directory expanded.
Step 2: Define Your Light and Dark Themes
Create lib/theme/app_theme.dart:
import 'package:flutter/material.dart';class AppTheme {
// Prevent instantiation
AppTheme._(); static const Color _lightSeedColor = Color(0xFF6750A4);
static const Color _darkSeedColor = Color(0xFF9A82DB); /// Light theme using Material 3
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: _lightSeedColor,
brightness: Brightness.light,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
),
);
} /// Dark theme using Material 3
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: _darkSeedColor,
brightness: Brightness.dark,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
filled: true,
),
);
}
}
Why this works: By using ColorScheme.fromSeed() with a brightness parameter, Flutter automatically generates a full, accessible color palette for both modes. You define one seed color — Flutter does the heavy lifting.
Step 3: Build the Theme Provider
Create lib/theme/theme_provider.dart. This is the core of your theme management:
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';class ThemeProvider extends ChangeNotifier {
static const String _themeModeKey = 'theme_mode'; ThemeMode _themeMode = ThemeMode.system; ThemeMode get themeMode => _themeMode; bool get isDarkMode => _themeMode == ThemeMode.dark; ThemeProvider() {
_loadThemeFromPrefs();
} /// Load persisted theme preference on startup
Future<void> _loadThemeFromPrefs() async {
final prefs = await SharedPreferences.getInstance();
final savedMode = prefs.getString(_themeModeKey); _themeMode = switch (savedMode) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system, // Default: respect OS setting
}; notifyListeners();
} /// Toggle between light and dark (ignores system setting)
Future<void> toggleTheme() async {
_themeMode = _themeMode == ThemeMode.dark
? ThemeMode.light
: ThemeMode.dark; await _saveThemeToPrefs();
notifyListeners();
} /// Set a specific theme mode
Future<void> setThemeMode(ThemeMode mode) async {
if (_themeMode == mode) return;
_themeMode = mode;
await _saveThemeToPrefs();
notifyListeners();
} Future<void> _saveThemeToPrefs() async {
final prefs = await SharedPreferences.getInstance(); final modeString = switch (_themeMode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
ThemeMode.system => 'system',
}; await prefs.setString(_themeModeKey, modeString);
}
}
Key points:
ThemeMode.systemis the default — your app respects the OS setting out of the box_loadThemeFromPrefs()runs in the constructor, so the correct theme loads before the first frame- The Dart 3
switchexpression keeps the code concise and exhaustive
Step 4: Wire Everything Together in main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';import 'theme/app_theme.dart';
import 'theme/theme_provider.dart';
import 'screens/home_screen.dart';void main() {
WidgetsFlutterBinding.ensureInitialized();
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(
title: 'My Flutter App',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeProvider.themeMode,
home: const HomeScreen(),
);
}
}
MaterialApp does the hard work here. Pass both theme and darkTheme, and let themeMode decide which one to render. When themeMode changes (via notifyListeners()), Flutter rebuilds MaterialApp and switches themes instantly — no full app restart needed.
Step 5: Build the Theme Toggle UI
Create lib/widgets/theme_toggle_button.dart:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';import '../theme/theme_provider.dart';class ThemeToggleButton extends StatelessWidget {
const ThemeToggleButton({super.key}); @override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final isDark = themeProvider.isDarkMode; return IconButton(
tooltip: isDark ? 'Switch to Light Mode' : 'Switch to Dark Mode',
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) => RotationTransition(
turns: animation,
child: FadeTransition(opacity: animation, child: child),
),
child: Icon(
isDark ? Icons.light_mode_rounded : Icons.dark_mode_rounded,
key: ValueKey(isDark),
),
),
onPressed: themeProvider.toggleTheme,
);
}
}
Now use it in your HomeScreen:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';import '../theme/theme_provider.dart';
import '../widgets/theme_toggle_button.dart';class HomeScreen extends StatelessWidget {
const HomeScreen({super.key}); @override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; return Scaffold(
appBar: AppBar(
title: const Text('Flutter Dark Mode'),
backgroundColor: colorScheme.surface,
actions: const [ThemeToggleButton()],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Always use colorScheme, never hardcoded colors
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Theme-aware Card',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'This card automatically adapts to light and dark mode.',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
const SizedBox(height: 16),
// Theme selector: System / Light / Dark
_ThemeModeSelector(),
],
),
),
);
}
}class _ThemeModeSelector extends StatelessWidget {
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>(); return SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
label: Text('System'),
icon: Icon(Icons.brightness_auto),
),
ButtonSegment(
value: ThemeMode.light,
label: Text('Light'),
icon: Icon(Icons.light_mode),
),
ButtonSegment(
value: ThemeMode.dark,
label: Text('Dark'),
icon: Icon(Icons.dark_mode),
),
],
selected: {themeProvider.themeMode},
onSelectionChanged: (modes) => themeProvider.setThemeMode(modes.first),
);
}
}
Visual note: Here you should show a side-by-side screenshot of the app in light mode (left) and dark mode (right), with the
SegmentedButtonvisible and theAppBartoggle icon clearly highlighted.
Common Mistakes to Avoid
1. Hardcoding Colors
// ❌ Wrong — breaks in dark mode
Container(color: Colors.white, child: Text('Hello', style: TextStyle(color: Colors.black)));// ✅ Correct — adapts automatically
Container(
color: Theme.of(context).colorScheme.surface,
child: Text('Hello', style: Theme.of(context).textTheme.bodyLarge),
);2. Using context.read() for Reactive UI
// ❌ Wrong — won't rebuild when theme changes
final provider = context.read<ThemeProvider>();// ✅ Correct — rebuilds on every theme change
final provider = context.watch<ThemeProvider>();3. Ignoring the system ThemeMode
Never default to ThemeMode.light. Your users may be on OLED displays at midnight. Always start with ThemeMode.system and let the OS decide until the user explicitly overrides.
4. Creating ThemeData Inside build()
// ❌ Wrong — recreates ThemeData on every rebuild (expensive)
theme: ThemeData(colorSchemeSeed: Colors.purple),// ✅ Correct — use a static getter or constant
theme: AppTheme.lightTheme,5. Forgetting WidgetsFlutterBinding.ensureInitialized()
If you use SharedPreferences before runApp(), you must call ensureInitialized() first. Skip it and your app crashes silently on some devices.
Best Practices
Use ColorScheme everywhere. Material 3 gives you primary, secondary, tertiary, surface, background, error, and their on-* counterparts. Learn them. Use them. Never hardcode hex values in widgets.
Get Nabil Krissane’s stories in your inbox
Join Medium for free to get updates from this writer.
Keep theme definitions in one file. AppTheme should be the single source of truth. If a designer changes the primary color, you change it in one place.
Test on both platforms. iOS and Android render system dark mode differently. Always test on both. Use MediaQuery.of(context).platformBrightness if you need to react to the OS setting imperatively.
Add a theme transition animation. Flutter switches themes instantly by default. Consider wrapping your MaterialApp with an AnimatedTheme or using page transitions that mask the switch for a polished feel.
Support high-contrast modes. Accessibility matters. Consider a third highContrastTheme using ThemeData.highContrastDark() as a base, activated by MediaQuery.of(context).highContrast.
Use Theme.of(context) sparingly in deep widget trees. Prefer passing semantic style objects (like TextStyle) down as parameters, rather than calling Theme.of(context) 15 levels deep.
Real-World Use Cases
Flutter dark mode implementation follows this exact pattern in production apps across multiple categories:
SaaS dashboards — Admin panels with data tables, charts, and forms need theme-aware widgets everywhere. The ColorScheme approach ensures charts, cards, and sidebars all adapt without per-widget overrides.
E-commerce apps — Product cards, price labels, and promotional banners must all look polished in dark mode. Using colorScheme.surface and colorScheme.onSurface correctly eliminates contrast issues.
Fintech and banking apps — These apps often run on OLED screens where true dark (#000000 background) saves battery. You can customize the ColorScheme to use a pure black surface while keeping Material 3's design language.
Developer tools — Apps like code editors or terminal emulators built in Flutter nearly always default to dark mode. The ThemeMode.dark default with an optional override is the pattern to follow.
Conclusion
Flutter dark mode doesn’t have to be painful. The key takeaways:
- Never hardcode colors — use
Theme.of(context).colorSchemeeverywhere - Define themes once in a dedicated
AppThemeclass - Use
ThemeMode.systemas the default — respect the OS setting - Persist user preference with
SharedPreferencesso it survives app restarts - Use
ChangeNotifier+Providerfor clean, reactive theme switching - Test on real devices — emulators don’t always simulate system dark mode accurately
With this architecture, adding dark mode support takes under an hour, scales to any app size, and gives your users a first-class experience on every device.
Take It Further: The Flutter Freelance Bundle
If you’re building Flutter apps for clients, you don’t need to spend hours setting up the same authentication flows, Firebase configuration, architecture, routing, and project structure for every new project.
That’s exactly why I created the Flutter Freelance Bundle.
It includes production-ready Flutter templates, Firebase authentication, clean architecture, state management, reusable UI components, API integration patterns, and the development workflow I use in real client projects.
Instead of starting from a blank screen every time, you can launch new projects faster, deliver to clients sooner, and focus on building features that actually matter.
Whether you’re a freelancer, agency owner, or developer working on multiple apps, the bundle helps you eliminate repetitive setup work and standardize your development process.
👉 Check out the Flutter Freelance Bundle →
It’s not a course or a collection of tutorials — it’s a practical set of ready-to-use Flutter foundations designed to help you ship production apps faster.
Have a question about Flutter theming or ran into an edge case not covered here? Drop a comment below — I read every one.









