Flutter Dark Mode: Light and Dark Theme with a Toggle That Remembers

Add dark mode to a Flutter app with Material 3. Create light and dark themes from one seed color, follow the system setting, add a toggle and save the user's choice with shared_preferences.

By Yaqoob Developer · · 5 min read

  • #Flutter
  • #Dark Mode
  • #Theme
  • #Material 3
  • #shared_preferences

Users expect dark mode. Many keep their phone in dark mode all day, and an app that flashes a bright white screen at night feels broken.

The good news: in Flutter, dark mode takes about 30 lines. In this guide you will:

  • Create a light and a dark theme from one seed color
  • Follow the phone's system setting automatically
  • Add a toggle with three choices: System, Light, Dark
  • Save the choice so the app remembers it after a restart
  • Avoid hard-coded colors that break in dark mode

Step 1: Two themes from one color

MaterialApp takes three theme properties:

  • theme: used in light mode
  • darkTheme: used in dark mode
  • themeMode: which one to use (system, light or dark)

With Material 3, you do not need to pick every color by hand. ColorScheme.fromSeed creates a full, balanced palette from one color, and the brightness parameter gives you the dark version:

import 'package:flutter/material.dart';

const seed = Color(0xFF6750A4);

final lightTheme = ThemeData(
  colorScheme: ColorScheme.fromSeed(seedColor: seed),
);

final darkTheme = ThemeData(
  colorScheme: ColorScheme.fromSeed(
    seedColor: seed,
    brightness: Brightness.dark,
  ),
);

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: lightTheme,
      darkTheme: darkTheme,
      themeMode: ThemeMode.system,
      home: const SettingsPage(),
    );
  }
}

Run the app and switch your phone or emulator to dark mode. The app follows it instantly, because ThemeMode.system is the default.

Step 2: Use theme colors, not fixed colors

This is where most dark-mode bugs come from. Look at this card:

Container(
  color: Colors.white,
  child: const Text('Hello', style: TextStyle(color: Colors.black)),
)

In dark mode it stays a bright white box. Instead, read colors from the theme:

final colors = Theme.of(context).colorScheme;

Container(
  color: colors.surfaceContainer,
  child: Text('Hello', style: TextStyle(color: colors.onSurface)),
)

The most useful ColorScheme colors:

ColorUse it for
primaryMain buttons, active icons, links
onPrimaryText and icons on top of primary
surfacePage background
surfaceContainerCards, sheets, input backgrounds
onSurfaceNormal text
onSurfaceVariantSecondary text, hints
outlineBorders and dividers
errorError messages

The rule: every on color is meant to sit on top of its partner. Use them in pairs and your text is always readable in both modes.

For text styles, use Theme.of(context).textTheme.titleLarge and friends. They already have the right color for the current mode.

Step 3: A ThemeController

To let the user change the mode, keep the current ThemeMode somewhere the whole app can see it. A ValueNotifier is enough, no package needed:

class ThemeController extends ValueNotifier<ThemeMode> {
  ThemeController() : super(ThemeMode.system);

  void setMode(ThemeMode mode) => value = mode;
}

final themeController = ThemeController();

Then rebuild MaterialApp whenever it changes:

@override
Widget build(BuildContext context) {
  return ValueListenableBuilder<ThemeMode>(
    valueListenable: themeController,
    builder: (context, mode, _) => MaterialApp(
      theme: lightTheme,
      darkTheme: darkTheme,
      themeMode: mode,
      home: const SettingsPage(),
    ),
  );
}

If your app already uses Provider or Riverpod, put ThemeMode in your state management instead. The idea is the same. See setState vs Provider if you are deciding.

Step 4: The toggle

A SegmentedButton is a clean Material 3 control for three choices:

class SettingsPage extends StatelessWidget {
  const SettingsPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Settings')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Text('Appearance', style: Theme.of(context).textTheme.titleMedium),
          const SizedBox(height: 12),
          ValueListenableBuilder<ThemeMode>(
            valueListenable: themeController,
            builder: (context, mode, _) => SegmentedButton<ThemeMode>(
              segments: const [
                ButtonSegment(
                  value: ThemeMode.system,
                  icon: Icon(Icons.brightness_auto),
                  label: Text('System'),
                ),
                ButtonSegment(
                  value: ThemeMode.light,
                  icon: Icon(Icons.light_mode),
                  label: Text('Light'),
                ),
                ButtonSegment(
                  value: ThemeMode.dark,
                  icon: Icon(Icons.dark_mode),
                  label: Text('Dark'),
                ),
              ],
              selected: {mode},
              onSelectionChanged: (s) => themeController.setMode(s.first),
            ),
          ),
        ],
      ),
    );
  }
}

Why three choices and not a simple on/off switch? A switch forces people to pick light or dark forever. "System" lets the app follow the phone's schedule, which is what most users want.

Step 5: Remember the choice

Right now the app forgets the setting when it restarts. Save it with shared_preferences (if you have not used it before, read save data locally with shared_preferences).

Add the package:

flutter pub add shared_preferences

Update the controller to load and save:

import 'package:shared_preferences/shared_preferences.dart';

class ThemeController extends ValueNotifier<ThemeMode> {
  ThemeController() : super(ThemeMode.system);

  static const _key = 'theme_mode';

  Future<void> load() async {
    final prefs = await SharedPreferences.getInstance();
    final saved = prefs.getString(_key);
    value = ThemeMode.values.firstWhere(
      (m) => m.name == saved,
      orElse: () => ThemeMode.system,
    );
  }

  Future<void> setMode(ThemeMode mode) async {
    value = mode;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_key, mode.name);
  }
}

And load it before runApp, so the first frame already has the right theme (no white flash):

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await themeController.load();
  runApp(const MyApp());
}

ensureInitialized() is required because shared_preferences talks to the platform before runApp has started Flutter.

Step 6: Fine-tune the dark theme

Seed colors give you a good start. To change specific parts, pass them to ThemeData:

final darkTheme = ThemeData(
  colorScheme: ColorScheme.fromSeed(
    seedColor: seed,
    brightness: Brightness.dark,
    surface: const Color(0xFF121212),
  ),
  cardTheme: const CardThemeData(elevation: 0),
);

Tips for a good dark theme:

  • Avoid pure black (#000000) backgrounds with pure white text. The contrast is tiring. Very dark grey and slightly grey text are easier on the eyes.
  • Check images and logos. A dark logo on a transparent PNG disappears on a dark background.
  • Test both modes on every new screen before you ship it.

Common mistakes

The app ignores dark mode. You set theme but not darkTheme. Without a darkTheme, Flutter uses the light theme in both modes.

Some text is invisible in dark mode. You hard-coded Colors.black somewhere. Search your project for Colors.black and Colors.white and replace them with ColorScheme colors.

White flash at startup. You load the saved setting after runApp. Load it before, like in Step 5.

See it in real designs

Dark UIs are some of the most popular designs on my channel. See the dark luxury login UI and the green dark sign-in UI for inspiration, with full source code.

Summary

  • Set theme, darkTheme and themeMode on MaterialApp.
  • Generate both palettes with ColorScheme.fromSeed.
  • Read colors from Theme.of(context).colorScheme, never hard-code black and white.
  • Keep the mode in a ValueNotifier, save it with shared_preferences, and load it before runApp.

Keep reading

More articles