Flutter Custom Theme with ThemeExtension + Templates

Alexander
9 min readJun 8, 2023
Flutter Custom Theme with ThemeExtension

In this article, I will guide you through the process of creating a custom theme in Flutter using ThemeExtensions. I will provide you with templates for Color and TextStyle extensions. Additionally, I will explain why I don’t recommend using the built-in ColorScheme and TextTheme if your design doesn’t follow the Material specifications (99% of cases).

Quicklinks:

  1. The Problem
  2. How to add custom colors
  3. How to implement Light and Dark modes
  4. How to add custom text styles
  5. Default TextStyle for the Text widget
  6. Ending (additional links)

The Problem

Every time you start a new Flutter project, you need to define reusable styles for your app. Usually, you define styles based on the design system, and it’s usually(never) the Material. TextStyle tokens (headlineLarge, bodyMedium, …) don’t follow the Material naming; design items use colors from the palette (red, grey, …) and not from the color scheme (accent, primary, background, …). You look at all of this and don’t know how to put it in your Flutter app.

First, what you might think of is extending ThemeData or writing your own custom theme class, but don’t do it. There is a much better and simpler way that integrates naturally into the Flutter app (ThemeExtension), which I will show you in this article. But let’s start with why I don’t recommend putting your design in the built-in Material ColorScheme and TextTheme.

Why ColorScheme and TextTheme aren’t good for your design

Flutter provides us with ThemeData class for configuring the global theme of the app. It contains many properties for many build-in widgets, but I would say that 2 main properties are ColorScheme and TextTheme. They are “ok”, but only if your design follows the Material specifications, in the other 99% of cases, your design system will have different naming and number of color and text style tokens.

Probably some color and text-style tokens will match, but the rest won’t. You might try to map some tokens (design: h1 -> code: headlineLarge), and put others in a separate place (ThemeExtension or something else). But this won’t be a good solution. It will result in hard-to-understand token names mapping and scattered declaration of the app theme that is hard to maintain and modify. Why would you even try to put a custom design system in the Material?

So I advise you to put all custom styles in the self-defined ThemeExtension(s) that you have full control of. Benefits:

  • You can add, rename, and delete any properties you need.
  • You don’t depend on Material specifications and Flutter updates.
  • All related styles are collected in 1 place and not scattered between ColorScheme and some ExtraColorScheme.

Moreover, it is very easy to write, especially with GitHub Copilot or templates I will give you.

And the reason why I advise you to still use Theme and ThemeData and not create your own class is that Flutter already has everything you will need using these classes: overriding theme for the part of UI, default styling for built-in widgets, setting default text style, support for changing between Light and Dark mode, etc. There is no reason to write your own class when it is easier to extend (ThemeExtensions) what already exists.

How to add custom colors in Flutter with ThemeExtension

To add custom colors to the Flutter app, you need 3 simple steps:

  1. Define custom colors.
  2. Create ThemeExtension for colors.
  3. Initialize extension for Light and Dark modes.

Step 1: AppPalette

AppPalette is a class where you define color codes used in your design system. It is nothing complicated, it is just a class with static properties:

abstract class AppPalette {
// Red
static const red = Colors.red;
static const imperialRed = Color(0xFFE54B4B);

// White
static const seashell = Color(0xFFF7EBE8);

// Grey
static const grey = _GreyColors();
}

/// Alternative way to group colors in the palette.
///
/// The downside is that you won't be able
/// to use them as constructor default values,
/// because they are not constants.
///
/// Usage example: `AppPalette.grey.grey50`.
class _GreyColors {
const _GreyColors();

final grey50 = const Color(0xFFFAFAFA);
final grey100 = const Color(0xFFF5F5F5);
}

Notes:

  • AppPalette is abstract because we don’t ever need to instantiate it.
  • _GreyColors is private because we don’t allow access to it directly but only through the AppPalette.

Step 2: AppColorsExtension + Template

Here we need to define our ThemeExtension and implement 2 methods: copyWith() and lerp(). Even in this example, with only primary and background properties, it might look like too much code for a simple task, but there is no way around it without code generation (link in the Ending section).

class AppColorsExtension extends ThemeExtension<AppColorsExtension> {
AppColorsExtension({
required this.primary,
required this.background,
});

final Color primary;
final Color background;

@override
ThemeExtension<AppColorsExtension> copyWith({
Color? primary,
Color? background,
}) {
return AppColorsExtension(
primary: primary ?? this.primary,
background: background ?? this.background,
);
}

@override
ThemeExtension<AppColorsExtension> lerp(
covariant ThemeExtension<AppColorsExtension>? other,
double t,
) {
if (other is! AppColorsExtension) {
return this;
}

return AppColorsExtension(
primary: Color.lerp(primary, other.primary, t)!,
background: Color.lerp(background, other.background, t)!,
);
}
}

Notes:

  • lerp() is used to linearly interpolate with another ThemeExtension object, and make an animation when changing the theme.

Here is the link to the full extension template. Right now, it has all the same properties as the build-in ColorScheme but you can add, rename, and remove whatever you need.

🔗 AppColorsExtension template

Step 3: Light and Dark modes

This step will be done in the next section 😅

Side note: I recommend you check out my other article on how to add the HTTP Authorization header to Chopper requests and retry on 401 Unauthorized response.

Now, back to the Flutter theming 🙂

How to implement Light and Dark modes in Flutter

Again, only 3 simple steps:

  1. Define Light and Dark ThemeData.
  2. Pass them to the MaterialApp.
  3. Pass the correct ThemeMode to the MaterialApp.

Step 1: AppTheme

This class will contain Flutter ThemeData getters for light and dark modes with our ThemeExtensions and the ability to switch between different theme modes programmatically. Firstly, let’s implement the getters.

class AppTheme {
//
// Light theme
//

static final light = ThemeData.light().copyWith(
extensions: [
_lightAppColors,
],
);

static final _lightAppColors = AppColorsExtension(
primary: const Color(0xff6200ee),
onPrimary: Colors.white,
secondary: const Color(0xff03dac6),
onSecondary: Colors.black,
error: const Color(0xffb00020),
onError: Colors.white,
background: Colors.white,
onBackground: Colors.black,
surface: Colors.white,
onSurface: Colors.black,
);

//
// Dark theme
//

static final dark = ThemeData.dark().copyWith(
extensions: [
_darkAppColors,
],
);

static final _darkAppColors = AppColorsExtension(
primary: const Color(0xffbb86fc),
onPrimary: Colors.black,
secondary: const Color(0xff03dac6),
onSecondary: Colors.black,
error: const Color(0xffcf6679),
onError: Colors.black,
background: const Color(0xff121212),
onBackground: Colors.white,
surface: const Color(0xff121212),
onSurface: Colors.white,
);
}

Notes:

  • Here I used the full version of AppColorsExtension from the template.

❗IMPORTANT. In the official documentation, they access extensions like this: Theme.of(context).extension<MyColors>()!, but this is too long and unusable. It is a shame that they don’t have a GOOD SOLUTION that uses the power of the Dart extension methods.

extension AppThemeExtension on ThemeData {
/// Usage example: Theme.of(context).appColors;
AppColorsExtension get appColors =>
extension<AppColorsExtension>() ?? AppTheme._lightAppColors;
}

Notes:

  • If you want this extension to have access to the private property AppTheme._lightAppColors it has to be written in one file with AppTheme.

❗ANOTHER IMPROVEMENT. Writing Theme.of(context) every single time might be too long, so I prefer to add another extension method on BuildContext:

extension ThemeGetter on BuildContext {
// Usage example: `context.theme`
ThemeData get theme => Theme.of(this);
}

The final usage will look like this: context.theme.appColors. Isn’t this nice? ✨ More useful extensions can be found in my article on Missing extensions in Flutter:

Step 2–3: How to change between Light and Dark modes in Flutter

To change between Light and Dark modes in Flutter, you need to specify theme and darkTheme properties in the MaterialApp. Additionally, you should also provide themeMode property that is responsible for determining the current theme mode of the app. ThemeMode is an enum with 3 options:

  1. ThemeMode.light
  2. ThemeMode.dark
  3. ThemeMode.system

For state management, I have used ChangeNotifier because I don’t feel a need to use anything more complex. If later you will need to change ThemeMode without BuildContext you can also register this class in get_it or any other package you use.

class AppTheme with ChangeNotifier {
ThemeMode _themeMode = ThemeMode.system;

ThemeMode get themeMode => _themeMode;

set themeMode(ThemeMode themeMode) {
_themeMode = themeMode;
notifyListeners();
}

...
}

Now, let’s put it in the MaterialApp:

void main() {
runApp(const MyApp());
}

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

@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
// 1. Provide AppTheme above the MaterialApp,
// so it will be available on all pages.
create: (_) => AppTheme(),
builder: (context, _) => MaterialApp(
title: 'Flutter Demo',
// 2. Provide light theme.
theme: AppTheme.light,
// 3. Provide dark theme.
darkTheme: AppTheme.dark,
// 4. Watch AppTheme changes (ThemeMode).
themeMode: context.watch<AppTheme>().themeMode,
debugShowCheckedModeBanner: false,
home: const MyHomePage(title: 'Flutter Demo Home Page'),
),
);
}
}

Notes:

  • ChangeNotifierProvider comes from the provider package, but I hope that you already know that 😅

Update ThemeMode:

void darkMode() {
context.read<AppTheme>().themeMode = ThemeMode.dark;
}

CONGRATULATIONS 🎉 You have just created a custom app theme with ThemeExtension for colors. But let’s also add custom text styles!

How to add custom text styles in Flutter with ThemeExtension

Because text styles don’t change between the Light and Dark modes, for most use cases, there is no need to create ThemeExtension for them, and simple const TextStyle declarations would be enough. But I will still show you both. Just 2 simple steps.

Step 1: AppTypography

This class serves the same purpose as the AppPalette, to define styles from the design in code.

abstract class AppTypography {
static const body1 = TextStyle(
fontSize: 16,
fontWeight: FontWeight.normal,
);

static const h1 = TextStyle(
fontSize: 96,
fontWeight: FontWeight.w300,
);
}

Notes:

  • If you liked the second approach of grouping colors with additional classes, you can use it here as well.

Now you can use these styles like this:

style: AppTypography.h1.copyWith(color: context.theme.appColors.error)

Default TextStyle for the Text widget in Flutter

To set the default TextStyle for the Text widget you need to set bodyMedium in the TextTheme. Here is an example:

static final light = () {
final defaultTheme = ThemeData.light();

return defaultTheme.copyWith(
textTheme: defaultTheme.textTheme.copyWith(
// Note: Default text style for Text widget.
bodyMedium: AppTypography.body1.copyWith(color: Colors.black),
),
extensions: [
_lightAppColors,
],
);
}();

Step 2: AppTextThemeExtension + Template

To create ThemeExtension for TextStyles, you need to follow the same approach as with colors.

class AppTextTheme extends ThemeExtension<AppTextTheme> {
const AppTextTheme({
required this.body1,
required this.h1,
});

final TextStyle body1;
final TextStyle h1;

@override
ThemeExtension<AppTextTheme> copyWith({
TextStyle? body1,
TextStyle? h1,
}) {
return AppTextTheme(
body1: body1 ?? this.body1,
h1: h1 ?? this.h1,
);
}

@override
ThemeExtension<AppTextTheme> lerp(
covariant ThemeExtension<AppTextTheme>? other,
double t,
) {
if (other is! AppTextTheme) {
return this;
}

return AppTextTheme(
body1: TextStyle.lerp(body1, other.body1, t)!,
h1: TextStyle.lerp(h1, other.h1, t)!,
);
}
}

Above is a simple example, but here is a full template that has all (not deprecated) properties as the built-in TextTheme. And you can add, rename, and delete whatever you need.

🔗 AppTextThemeExtension template

Don’t forget to add this extension to the Light and Dark ThemeData:

class AppTheme with ChangeNotifier {

...

static final light = () {
final defaultTheme = ThemeData.light();

return defaultTheme.copyWith(
textTheme: defaultTheme.textTheme.copyWith(
// Note: Default text style for Text widget.
bodyMedium: AppTypography.body1.copyWith(color: Colors.black),
),
extensions: [
_lightAppColors,
// 1. Here
_lightTextTheme,
],
);
}();

static final _lightAppColors = ...;

// 2. Here
static final _lightTextTheme = AppTextThemeExtension(
body1: AppTypography.body1.copyWith(color: _lightAppColors.onBackground),
h1: AppTypography.h1.copyWith(color: Colors.black),
);

...

}

extension AppThemeExtension on ThemeData {
AppColorsExtension get appColors =>
extension<AppColorsExtension>() ?? AppTheme._lightAppColors;

// 3. And here
AppTextThemeExtension get appTextTheme =>
extension<AppTextThemeExtension>() ?? AppTheme._lightTextTheme;
}

CONGRATULATIONS AGAIN 🎉 Now you have ThemeExtension for TextStyles. And I invite you to look at the full source code below.

Ending

Source code

Templates:

Full app example:

What can be extended

  1. Register AppTheme in get_it or any other package to access it without the BuildContext.
  2. Store selected ThemeMode in the shared_preferences.

Flutter ThemeExtension generator

I have seen a package that generates ThemeExtension, but I’ve never used it because, for me, less generation is always better. Moreover, with GitHub Copilot, it’s very easy to write necessary boilerplate code.

Further reading

Thank you for reading. Bye 👋

--

--