Learn to implement Flutter theme switch with system adaptation, manual toggle and local persistence in 15 minutes. Step-by-step guide with copy-paste code, Provider state management, shared_preferences usage and common pitfalls to avoid for Flutter developers.
As a Flutter developer, I add theme switching to nearly every app I build. Itās not about showing off; itās about meeting real user needs: eye protection for late-night app usage, personalization for different preferences, accessibility support for users with low vision, and even a touch of polish that sets a product apartā all from this seemingly simple feature.
Today, Iām sharing the streamlined, practical solution Iāve refined through real-world projects. No complex logic requiredā youāll have a complete theme system with system auto-adaptation, manual toggle, and local persistence up and running in 15 minutes. All code is copy-paste ready, and Iāll throw in the pitfalls Iāve encountered to help you avoid common mistakes.
Why You Need Flutter Theme Switching
Before diving in, letās get one thing clear: theme switching isnāt aānice-to-haveāā itās a baseline expectation for modern mobile apps. Dark mode, in particular, is now a native feature on both iOS and Android, and users expect apps to keep up.
- System-Level User Expectations: Most users toggle their system theme based on the time of day. If your app doesnāt adapt, it will feel jarring and may even drive users away;
- Personalization & User Retention: Letting users choose their preferred theme caters to individual visual tastes, boosts product likability, and indirectly increases user engagement;
- Accessibility Compliance: High-contrast light and dark themes are easier on users with low vision, meeting mobile app accessibility standards and avoiding potential compliance risks;
- Technical Attention to Detail: While simple in appearance, theme switching involves state management, local persistence, and system integrationā itās a small feature that showcases your commitment to quality.
Practical Setup: Environment & Dependencies
First, create a new Flutter project (skip this if youāre working with an existing one). Then, add two core dependencies to your pubspec.yamlā these are the most stable, widely used tools for this task, so no need for complex frameworks.
dependencies:
flutter:
sdk: flutter
provider: ^6.1.1 # Core state management to listen for theme changes and update UI
shared_preferences: ^2.2.2 # Local persistence to save user theme preferences between app restarts
After adding these, run flutter pub get to install the dependencies. A quick tip: Stick to the versions Iāve listed (or compatible ones) to avoid API breaking changes in newer releasesā this is the first pitfall I learned the hard way, and Iām passing that lesson on.
Step 1: Define Theme Data (Light & Dark, Copy-Paste Ready)
The heart of any theme system is ThemeData. Weāll define separate light and dark themes to centralize colors, fonts, icons, and other stylesā this way, you wonāt be hunting for code when you need to make changes later.
Create a theme folder in your lib directory, then add an app_theme.dart file. The code below is optimized for consistent styling across all components:
import 'package:flutter/material.dart';
class AppTheme {// Light Theme (Default)
static final ThemeData lightTheme = ThemeData(
brightness: Brightness.light,
primarySwatch: Colors.blue, // Primary colorā customize to match your brand
scaffoldBackgroundColor: Colors.grey[50], // Softer than pure white for eye comfort
appBarTheme: const AppBarTheme(
elevation: 0, // Remove AppBar shadow for a cleaner look
backgroundColor: Colors.blue,
foregroundColor: Colors.white, // Title and icon color
titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
),
textTheme: TextTheme(
// Heading style
titleLarge: const TextStyle(color: Colors.black87, fontSize: 20, fontWeight: FontWeight.bold),
// Body text styles
bodyLarge: TextStyle(color: Colors.black87, fontSize: 16),
bodyMedium: TextStyle(color: Colors.black54, fontSize: 14),
// Secondary text style
labelMedium: TextStyle(color: Colors.black45, fontSize: 12),
),
iconTheme: const IconThemeData(color: Colors.black87, size: 24),
// Standardize ElevatedButton styles to avoid repetitive code
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
);
// Dark Theme (For system dark mode + manual toggle)
static final ThemeData darkTheme = ThemeData(
brightness: Brightness.dark,
primarySwatch: Colors.blueGrey, // Dark mode primary colorā not too bright
scaffoldBackgroundColor: Colors.grey[900], // Dark background for eye protection
appBarTheme: AppBarTheme(
elevation: 0,
backgroundColor: Colors.grey[850],
foregroundColor: Colors.white,
titleTextStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
),
textTheme: TextTheme(titleLarge: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
bodyLarge: TextStyle(color: Colors.white70, fontSize: 16),
bodyMedium: TextStyle(color: Colors.white60, fontSize: 14),
labelMedium: TextStyle(color: Colors.white40, fontSize: 12),
),
iconTheme: const IconThemeData(color: Colors.white70, size: 24),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
);
}
Two practical tips from my experience:
- I use
Colors.grey[50]for the light theme background instead ofColors.whiteā itās easier on the eyes during long reading sessions; - Standardizing
elevatedButtonThemesaves time laterā no need to style each button individually, and it ensures a consistent UI.
If your app needs multiple theme colors (e.g., red, green), you can parameterize the colorsā Iāll cover this in the advanced section later.
Step 2: Core Implementation: Theme Provider (State Management + Persistence)
The core of theme switching lies in state management and persistence: the UI needs to update in real time when the theme changes, and the userās choice should persist between app restarts. Weāll use ChangeNotifier + Provider for state management andshared_preferences for local persistenceā a simple, reliable combination.
Create a providers folder in your lib directory, then add a theme_provider.dart file. The code below includes detailed comments and highlights key steps:
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../theme/app_theme.dart';
// Enum to define theme modesā keeps code organized and avoids confusion
enum ThemeModeType {
light, // Light mode
dark, // Dark mode
system, // Follow system settings (default)
}
class ThemeProvider extends ChangeNotifier {
ThemeModeType _themeMode = ThemeModeType.system; // Default to system mode
late SharedPreferences _prefs; // For local persistence
// Initialize: Load saved theme mode from local storage to avoid resetting on restart
Future<void> init() async {_prefs = await SharedPreferences.getInstance();
// Read saved theme mode (key: 'themeMode'ā null on first app launch)
final String? savedMode = _prefs.getString('themeMode');
if (savedMode != null) {
// Convert saved string back to ThemeModeType; default to system if not found
_themeMode = ThemeModeType.values.firstWhere((e) => e.toString() == savedMode,
orElse: () => ThemeModeType.system,);
}
notifyListeners(); // Notify UI to update with the saved theme}
// Expose current theme mode (read-only)
ThemeModeType get themeMode => _themeMode;
// Get the actual ThemeData to use, based on current theme mode (requires BuildContext for system brightness)
ThemeData getTheme(BuildContext context) {switch (_themeMode) {
case ThemeModeType.light:
return AppTheme.lightTheme;
case ThemeModeType.dark:
return AppTheme.darkTheme;
case ThemeModeType.system:
// Get system brightness to determine light/dark mode
final brightness = MediaQuery.platformBrightnessOf(context);
return brightness == Brightness.dark ? AppTheme.darkTheme : AppTheme.lightTheme;
}
}
// Toggle theme mode and save to local storage
Future<void> setThemeMode(ThemeModeType mode) async {if (_themeMode == mode) return; // Avoid unnecessary updates to improve performance
_themeMode = mode;
// Save to local storage (store enum as string)
await _prefs.setString('themeMode', mode.toString());
notifyListeners(); // Notify UI to update with the new theme}
// Check if dark mode is active (for UI-specific adjustments)
bool isDarkMode(BuildContext context) {final theme = getTheme(context);
return theme.brightness == Brightness.dark;
}
}
A critical pitfall to avoid: Both getTheme and isDarkMode require a BuildContext because we use MediaQuery.platformBrightnessOf(context) to get the systemās brightness. Without a context, this will throw an error.
Additionally, the init method is asynchronousā it must complete before the app starts, otherwise youāll see a brief flash of the default theme before the saved theme loads. Weāll fix this in main.dart next.
Step 3: Inject Provider at the Top Level for Global Access
The theme provider needs to be accessible throughout the app, so weāll inject it at the top level in main.dart. Weāll also ensure it initializes before the app starts to avoid the theme flash issue.
Update your main.dart with the following code:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/theme_provider.dart';
import 'screens/home_screen.dart';
import 'screens/settings_screen.dart';
void main() async {
// Ensure Flutter is fully bound before initializing SharedPreferences
WidgetsFlutterBinding.ensureInitialized();
// Initialize ThemeProvider and load saved theme settings
final themeProvider = ThemeProvider();
await themeProvider.init();
runApp(// Use MultiProvider to easily add other providers (e.g., user state, network state) later
MultiProvider(
providers: [
// Inject ThemeProvider using .value to avoid recreating the instance
ChangeNotifierProvider.value(value: themeProvider),
],
child: const MyApp(),),
);
}
class MyApp extends StatelessWidget {const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// Listen for changes to ThemeProviderā UI will rebuild when the theme changes
final themeProvider = Provider.of<ThemeProvider>(context);
return MaterialApp(
title: 'Flutter Theme Switch Tutorial',
// Key: Use Flutter's recommended approach to adapt to system themes
theme: AppTheme.lightTheme, // Default light theme
darkTheme: AppTheme.darkTheme, // Default dark theme
// Map our custom ThemeModeType to Flutter's native ThemeMode
themeMode: themeProvider.themeMode == ThemeModeType.system
? ThemeMode.system
: themeProvider.themeMode == ThemeModeType.light
? ThemeMode.light
: ThemeMode.dark,
// Route management for home and settings screens (theme toggle lives in settings)
routes: {'/': (context) => const HomeScreen(),
'/settings': (context) => const SettingsScreen(),},
);
}
}
Two practical optimizations from real-world projects:
- Initializing
ThemeProviderbeforerunAppeliminates the theme flash issue entirely; - Using
MultiProvidermakes it easy to add other global state (e.g., user info, network status) later without restructuring the app; - Route management keeps the home and settings screens organizedā a best practice for scalable apps.
Step 4: Build the Theme Toggle UI (User-Friendly Manual Switch)
Next, weāll create a settings screen where users can choose betweenāFollow System,āāLight Mode,āandāDark Mode.āWeāll also add a real-time preview so users can see the theme change instantly.
Create a screens folder in your lib directory, then add a settings_screen.dart file. The code below is clean, user-friendly, and fully theme-adaptive:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/theme_provider.dart';
class SettingsScreen extends StatelessWidget {const SettingsScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {final themeProvider = Provider.of<ThemeProvider>(context);
final currentMode = themeProvider.themeMode;
return Scaffold(
appBar: AppBar(title: const Text('Settings'),
leading: IconButton(icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
body: ListView(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
children: [
// Theme mode heading
const Text(
'Theme Mode',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
// Follow System option
RadioListTile<ThemeModeType>(title: const Text('Follow System'),
subtitle: const Text('Automatically switch between light and dark based on system settings'),
value: ThemeModeType.system,
groupValue: currentMode,
onChanged: (value) {if (value != null) {themeProvider.setThemeMode(value);
}
},
activeColor: Theme.of(context).primaryColor,
),
// Light Mode option
RadioListTile<ThemeModeType>(title: const Text('Light Mode'),
subtitle: const Text('Always use light themeā ideal for daytime use'),
value: ThemeModeType.light,
groupValue: currentMode,
onChanged: (value) {if (value != null) {themeProvider.setThemeMode(value);
}
},
activeColor: Theme.of(context).primaryColor,
),
// Dark Mode option
RadioListTile<ThemeModeType>(title: const Text('Dark Mode'),
subtitle: const Text('Always use dark themeā easier on the eyes at night'),
value: ThemeModeType.dark,
groupValue: currentMode,
onChanged: (value) {if (value != null) {themeProvider.setThemeMode(value);
}
},
activeColor: Theme.of(context).primaryColor,
),
const SizedBox(height: 30),
const Divider(),
const SizedBox(height: 30),
// Real-time theme preview card
Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Real-Time Preview',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
),
const SizedBox(height: 20),
// Preview content: Simulate common app components
Row(
children: [
Icon(themeProvider.isDarkMode(context)
? Icons.dark_mode
: Icons.light_mode,
size: 28,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Current Theme',
style: Theme.of(context).textTheme.bodyLarge,
),
Text(themeProvider.isDarkMode(context) ? 'Dark Mode' : 'Light Mode',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
const SizedBox(height: 20),
// Preview button and text
ElevatedButton(onPressed: () {},
child: const Text('Preview Button'),
),
const SizedBox(height: 16),
Text(
'This is a preview text to show how text colors adapt to the theme.',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
],
),
);
}
}
Next, create a home_screen.dart file in the screens folder. Add a button to navigate to the settings screen, so users can easily access the theme toggle:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/theme_provider.dart';
import 'settings_screen.dart';
class HomeScreen extends StatelessWidget {const HomeScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {final themeProvider = Provider.of<ThemeProvider>(context);
return Scaffold(
appBar: AppBar(title: const Text('Home'),
actions: [
// Button to navigate to settings
IconButton(icon: const Icon(Icons.settings),
onPressed: () => Navigator.pushNamed(context, '/settings'),
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Current Theme: ${themeProvider.isDarkMode(context) ?'Dark Mode':'Light Mode'}',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 20),
ElevatedButton(onPressed: () => Navigator.pushNamed(context, '/settings'),
child: const Text('Go to Settings to Switch Theme'),
),
],
),
),
);
}
}
Thatās it for the core functionality: Users can switch themes in the settings screen, see a real-time preview, and their choice will persist even after restarting the app. The app will also automatically adapt when the user changes their system theme.
Practical Pitfall Guide (Must-Read)
These are the most common mistakes Iāve seen (and made) when implementing theme switching. Avoiding them will save you hours of debugging:
- Hot Reload Not Working: If theme changes donāt show up after a hot reload, donāt waste time troubleshootingā just restart the app. This is a minor Flutter quirk with theme data, and thereās no easy fix yet;
- Theme Flash Issue: Always initialize
ThemeProviderbeforerunApp. If you donāt, youāll see a brief flash of the default theme before the saved one loadsā themain.dartcode above fixes this; - Provider Access Errors: Make sure
Provider.of<ThemeProvider>(context)is used within theChangeNotifierProvidersubtree. If you need to access it at the top level, addlisten: false; - Hardcoded Colors Breaking Adaptation: Never hardcode colors (e.g.,
Colors.white,Colors.black) in custom components. UseTheme.of(context).colorSchemeorTheme.of(context).textThemeinsteadā for example, usecolorScheme.surfacefor backgrounds andcolorScheme.onSurfacefor text. This ensures components adapt automatically to theme changes; - No Response to System Theme Changes: You donāt need extra code to listen for system theme changes. As long as you set
themeMode: ThemeMode.systeminMaterialApp, Flutter will automatically update the app theme when the system theme changes.
Advanced: Add Multiple Theme Colors (Optional)
If your app needs support for multiple theme colors (e.g., red, green, blue), you can extend the code above by parameterizing colors to generateThemeData dynamically.
Hereās a quick implementation outline:
- Add a primary color field to
ThemeProvider, e.g.,Color _primaryColor = Colors.blue;; - Define multiple color options (e.g., red, green) and add a
setPrimaryColormethod to switch between themā donāt forget to save the choice to local storage; - Update the
getThememethod to generateThemeDatadynamically based on the current theme mode and primary color. UseThemeData.copyWithorThemeData.fromfor this.
You can customize the code based on your brandās color paletteā the core logic is the same as the theme switching weāve already implemented, so no need for new frameworks.
Complete Directory Structure (Best Practices)
To keep your project organized (and easy to scale), hereās the standard directory structure I use for theme implementation:
lib/
āāā main.dart # App entry point, injects Provider
āāā providers/ # State management folder
ā āāā theme_provider.dart # Theme manager
āāā screens/ # Screen folder
ā āāā home_screen.dart # Home screen
ā āāā settings_screen.dart # Settings screen (theme toggle)
āāā theme/ # Theme configuration folder
āāā app_theme.dart # Light/dark theme definitions
Conclusion
Flutter theme switching is simpler than you might think. The core process boils down to three steps: define theme data, manage theme state, and inject the provider for global access. With less than 100 lines of core code, you can implement a complete system with system adaptation, manual toggle, and local persistenceā all of which boost user experience.
Dark mode is no longer a premium featureā itās a baseline expectation for modern Flutter apps. The code in this tutorial is copy-paste ready; just customize the primary colors in AppTheme to match your brand, and youāll be up and running in minutes.
If you run into issues during implementation, feel free to leave a comment below. Iāve been there, and Iām happy to helpā after all, the best lessons come from the pitfalls weāve all stepped into.