UI/UX

Implementing Dark Mode in Flutter: Complete Guide

Muhammad Shakil Muhammad Shakil
Mar 31, 2026
12 min read
Implementing dark mode in Flutter — complete guide
Back to Blog

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:

  1. 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%.
  2. 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.
  3. 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:

  1. Hardcoded colors in widgets. Search your codebase for Color(0x and Colors. in widget files. Every instance is a potential dark mode bug. Replace them with Theme.of(context).colorScheme references.
  2. Using Colors.black and Colors.white for text. Use colorScheme.onSurface instead. Black text on a #121212 dark surface is invisible.
  3. Forgetting Scaffold background. If you're setting backgroundColor on Scaffold explicitly, you're overriding the theme. Remove it — the theme handles it.
  4. 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.
  5. 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.
  6. 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_splash configuration to your dark theme, or use the android12 configuration for adaptive splash screens.

Related Guides

🔗 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.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.