Dark mode implementation in Flutter
By John · 7 July 2025
Dark mode is now expected on both iOS and Android. Flutter's theming system makes it straightforward to support both, but doing it right — consistent colors, proper image handling, no flash on launch — takes care.
Define your themes
class AppTheme {
static ThemeData get light => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6750A4),
brightness: Brightness.light,
),
// Override specific tokens
cardTheme: const CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
);
static ThemeData get dark => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6750A4),
brightness: Brightness.dark,
),
cardTheme: const CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
);
}
// In MaterialApp:
MaterialApp(
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: ThemeMode.system, // Follow system setting
)
Let users override the system setting
// Store the user's preference
const _kThemeKey = 'theme_mode';
class ThemeRepository {
final SharedPreferences _prefs;
ThemeRepository(this._prefs);
ThemeMode getThemeMode() {
final value = _prefs.getString(_kThemeKey);
return switch (value) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
}
Future<void> setThemeMode(ThemeMode mode) async {
final value = switch (mode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
ThemeMode.system => 'system',
};
await _prefs.setString(_kThemeKey, value);
}
}
// Riverpod provider
@riverpod
class ThemeModeNotifier extends _$ThemeModeNotifier {
@override
ThemeMode build() {
return ref.read(themeRepositoryProvider).getThemeMode();
}
Future<void> setMode(ThemeMode mode) async {
await ref.read(themeRepositoryProvider).setThemeMode(mode);
state = mode;
}
}
// Wire it up
Consumer(
builder: (context, ref, child) {
final themeMode = ref.watch(themeModeNotifierProvider);
return MaterialApp(
theme: AppTheme.light,
darkTheme: AppTheme.dark,
themeMode: themeMode,
);
},
)
Using colors correctly in widgets
Always use Theme.of(context).colorScheme — never hard-code hex values in widgets:
// WRONG
container color: const Color(0xFFFFFFFF),
text style: TextStyle(color: Colors.black87),
// RIGHT
Container(
color: Theme.of(context).colorScheme.surface,
child: Text(
'Hello',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
)
Key M3 color roles:
surface / onSurface — cards, dialogs, backgrounds
primary / onPrimary — prominent buttons, FABs
primaryContainer / onPrimaryContainer — filled chips, tonal surfaces
error / onError — error states
outline — borders
outlineVariant — dividers, subtle borders
Custom semantic colors
For app-specific colors (e.g., success, warning, custom brand colors):
extension AppColorScheme on ColorScheme {
Color get success => brightness == Brightness.light
? const Color(0xFF2E7D32)
: const Color(0xFF81C784);
Color get onSuccess => brightness == Brightness.light
? Colors.white
: const Color(0xFF1B5E20);
Color get warning => brightness == Brightness.light
? const Color(0xFFF57C00)
: const Color(0xFFFFB74D);
}
// Usage:
Color successColor = Theme.of(context).colorScheme.success;
Status bar and navigation bar theming
// Set status bar color based on theme
class _MyAppState extends State<MyApp> {
@override
void didChangePlatformBrightness() {
super.didChangePlatformBrightness();
_updateSystemUI();
}
void _updateSystemUI() {
final brightness = WidgetsBinding.instance.platformDispatcher.platformBrightness;
final isDark = brightness == Brightness.dark;
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarBrightness: isDark ? Brightness.dark : Brightness.light,
statusBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
systemNavigationBarColor: isDark ? const Color(0xFF1C1B1F) : Colors.white,
systemNavigationBarIconBrightness: isDark ? Brightness.light : Brightness.dark,
));
}
}
Images for dark mode
// Different images per theme
Image.asset(
isDarkMode ? 'assets/logo_dark.png' : 'assets/logo_light.png',
)
// Or use SVG with currentColor
// SVGs that reference 'currentColor' adapt to text color automatically
SvgPicture.asset(
'assets/icon.svg',
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
)
Avoiding a white flash on dark-mode launch
The native splash screen shows before Flutter loads. Configure it to match:
Android — android/app/src/main/res/values-night/styles.xml:
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">#1C1B1F</item>
</style>
iOS — set UIBackgroundColor in Info.plist or use flutter_native_splash which handles this automatically.
Detecting current mode in widgets
bool isDark = Theme.of(context).brightness == Brightness.dark;
// Or
bool isDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
Prefer the Theme approach (respects user override), not MediaQuery (reflects system only).
Common pitfalls
Hard-coding black or white. Colors.black is invisible on a dark background, Colors.white pops badly in dark mode. Use colorScheme.onSurface instead.
Not testing dark mode during development. Enable dark mode on the simulator/emulator from settings and keep it there while building UI. Issues that are obvious in dark mode are invisible in light mode.
Custom painters ignoring brightness. CustomPainter draws with explicit Paint() colors — these don't adapt automatically. Check brightness in the painter:
@override
void paint(Canvas canvas, Size size) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final paint = Paint()..color = isDark ? Colors.white54 : Colors.black54;
// ...
}
Sign in to like, dislike, or report.