Theme any screen with your brand
Make library screens match your brand from one file. Color schemes, light and dark, fonts, component shapes, custom tokens, and how to fix hard-coded colors.
Library screens read their colors and text styles from Theme.of(context), so a good ThemeData restyles most of them with no edits. The rest have a few hard-coded colors that break in dark mode or clash with your brand. This guide sets up the theme once, then fixes those.
You’ll work with two screens that show both cases:
- Login Screen is fully theme-driven
- Sign Up with terms hard-codes its password strength colors
Loading preview
Starting the Flutter app
Setup
fluttertemplates add auth/login_hero auth/signup_terms
The screens land under lib/fluttertemplates/. Without the CLI, copy each file from its widget page into the same path.
Generate the color scheme
ColorScheme.fromSeed builds a full palette from one color, with text colors that have enough contrast on each surface. Every library screen uses roles from this scheme, such as primary, onPrimary, primaryContainer, and surfaceContainerHighest.
By default, the generated primary is a toned-down relative of your seed. If your brand color must appear exactly, use DynamicSchemeVariant.fidelity, which stays much closer to the seed, and check the result by eye.
Add your own tokens
A color scheme has no role for “success” or “warning”, and those are exactly the colors the library hard-codes. ThemeExtension adds your own tokens to the theme, including dark mode values and animated theme changes.
// lib/theme/brand_colors.dart
import 'package:flutter/material.dart';
@immutable
class BrandColors extends ThemeExtension<BrandColors> {
const BrandColors({
required this.success,
required this.warning,
required this.caution,
});
final Color success;
final Color warning;
final Color caution;
static const light = BrandColors(
success: Color(0xFF1B7F4B),
warning: Color(0xFFB85C00),
caution: Color(0xFF8A6D00),
);
static const dark = BrandColors(
success: Color(0xFF6FDBA1),
warning: Color(0xFFFFB871),
caution: Color(0xFFE9C949),
);
@override
BrandColors copyWith({Color? success, Color? warning, Color? caution}) {
return BrandColors(
success: success ?? this.success,
warning: warning ?? this.warning,
caution: caution ?? this.caution,
);
}
@override
BrandColors lerp(BrandColors? other, double t) {
if (other == null) return this;
return BrandColors(
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
caution: Color.lerp(caution, other.caution, t)!,
);
}
}
extension BrandColorsContext on BuildContext {
BrandColors get brandColors => Theme.of(this).extension<BrandColors>()!;
}
lerp matters. Flutter uses it to animate between light and dark, so returning a fixed value makes your tokens jump while everything else fades.
Build the theme
One function, called for each brightness, keeps light and dark in sync.
// lib/theme/app_theme.dart
import 'package:flutter/material.dart';
import 'brand_colors.dart';
const _brand = Color(0xFF0F766E);
ThemeData _buildTheme(Brightness brightness) {
final scheme = ColorScheme.fromSeed(
seedColor: _brand,
brightness: brightness,
dynamicSchemeVariant: DynamicSchemeVariant.fidelity,
);
final radius = BorderRadius.circular(12);
return ThemeData(
colorScheme: scheme,
fontFamily: 'Inter',
extensions: [
brightness == Brightness.light ? BrandColors.light : BrandColors.dark,
],
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: radius),
minimumSize: const Size(0, 48),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: radius),
),
),
cardTheme: CardThemeData(
elevation: 0,
color: scheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: radius),
),
appBarTheme: const AppBarTheme(centerTitle: false),
);
}
final lightTheme = _buildTheme(Brightness.light);
final darkTheme = _buildTheme(Brightness.dark);
Add your font
fontFamily needs a font that’s bundled with the app. Download the files, for example Inter from Google Fonts, put them in assets/fonts/, and declare them in pubspec.yaml. Each weight you declare is a real file, so bold text doesn’t get faked:
flutter:
uses-material-design: true
fonts:
- family: Inter
fonts:
- asset: assets/fonts/Inter-Regular.ttf
- asset: assets/fonts/Inter-Medium.ttf
weight: 500
- asset: assets/fonts/Inter-SemiBold.ttf
weight: 600
- asset: assets/fonts/Inter-Bold.ttf
weight: 700
Bundled fonts work offline and don’t flash a fallback font on first launch. Restart the app after changing pubspec.yaml, because hot reload doesn’t pick up new assets.
Apply it
themeMode picks between the two. ThemeMode.system follows the device. The switch below is here to make testing easy, and in a real app you’d save the choice with something like shared_preferences.
// lib/main.dart
import 'package:flutter/material.dart';
import 'fluttertemplates/auth/login_hero.dart';
import 'fluttertemplates/auth/signup_terms.dart';
import 'theme/app_theme.dart';
void main() => runApp(const MyApp());
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
var _mode = ThemeMode.system;
void _toggle(bool dark) {
setState(() => _mode = dark ? ThemeMode.dark : ThemeMode.light);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: lightTheme,
darkTheme: darkTheme,
themeMode: _mode,
home: DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Theme check'),
actions: [Switch(value: _mode == ThemeMode.dark, onChanged: _toggle)],
bottom: const TabBar(
tabs: [Tab(text: 'Login'), Tab(text: 'Sign up')],
),
),
body: const TabBarView(children: [LoginHero(), SignupTerms()]),
),
),
);
}
}
LoginHero picks up everything with no edits: the new primary color, font, button shape, and dark mode.
Fix hard-coded colors
Find them in any screen you’ve added:
grep -rn "Colors\." lib/fluttertemplates
Then sort each match:
| What you find | What to do |
|---|---|
Colors.white or Colors.black on a colored surface |
Use the matching on... role, such as scheme.onPrimary |
Colors.grey[300] for a track or divider |
scheme.surfaceContainerHighest or scheme.outlineVariant |
Colors.grey for secondary text |
scheme.onSurfaceVariant |
Colors.red for errors |
scheme.error |
| Green, orange, or amber meaning status | A BrandColors token |
Colors.primaries[...] for placeholder images |
Leave it, because it goes away with real images |
signup_terms.dart has the status case. Its strength bar uses Colors.orange, Colors.amber, and Colors.green. Import ../../theme/brand_colors.dart, then change the list in build:
final brand = context.brandColors;
final colors = [
scheme.error,
scheme.error,
brand.warning,
brand.caution,
brand.success,
];
Now the colors are defined once, tuned for each brightness, and consistent with every other status color in your app.
The Sign Up screen has the track case, with backgroundColor: Colors.grey[300] on its strength bar. In dark mode that’s a bright stripe across a dark screen. The fix is one line:
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
Screens that set their own theme
Responsive Navigation Bar wraps itself in Theme(data: ThemeData.dark(), ...), so your app theme stops at that widget. Delete the Theme wrapper and keep its child. Its Scaffold then follows your theme, and the Colors.green logo text should move to scheme.primary.
To theme one section differently on purpose, inherit from the app theme rather than starting over:
Theme(
data: Theme.of(context).copyWith(
colorScheme: Theme.of(context).colorScheme.copyWith(
primary: Theme.of(context).colorScheme.tertiary,
),
),
child: const LoginHero(),
);
If you’d rather use google_fonts
The google_fonts package fetches fonts at runtime, which is handy while you’re trying out options. Current versions have a catch. Since version 9 it’s built on the separate material_ui package, and its TextTheme isn’t the same class as the one in package:flutter/material.dart, which is what library screens and new Flutter projects import. So the usual one-liner doesn’t compile:
The argument type 'TextTheme' can't be assigned to the parameter type 'TextTheme?'.
TextStyle is shared between the two, so apply the font style by style. Each style keeps its weight, and that’s what selects the right font file:
// lib/theme/inter_text_theme.dart
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
TextTheme interTextTheme(TextTheme base) {
TextStyle inter(TextStyle? style) => GoogleFonts.inter(textStyle: style);
return base.copyWith(
displayLarge: inter(base.displayLarge),
displayMedium: inter(base.displayMedium),
displaySmall: inter(base.displaySmall),
headlineLarge: inter(base.headlineLarge),
headlineMedium: inter(base.headlineMedium),
headlineSmall: inter(base.headlineSmall),
titleLarge: inter(base.titleLarge),
titleMedium: inter(base.titleMedium),
titleSmall: inter(base.titleSmall),
bodyLarge: inter(base.bodyLarge),
bodyMedium: inter(base.bodyMedium),
bodySmall: inter(base.bodySmall),
labelLarge: inter(base.labelLarge),
labelMedium: inter(base.labelMedium),
labelSmall: inter(base.labelSmall),
);
}
Remove fontFamily from _buildTheme, then apply this to the finished theme. Passing the theme’s own textTheme keeps the text colors right for each brightness:
final theme = _buildTheme(Brightness.light);
final lightTheme = theme.copyWith(textTheme: interTextTheme(theme.textTheme));
Runtime fetching means a flash of the fallback font on first launch, an external request, and no custom font offline. For production, bundle the font as shown above.
Check your work
- Toggle dark mode on every screen you’ve added. Look for bright patches, invisible text, and icons that vanish.
- Run the
grepagain and account for each remaining match. - Check contrast on your primary color.
fromSeedhandles generated pairs, but custom tokens likeBrandColorsare on you. Aim for 4.5:1 for body text.
Common mistakes
Calling Theme.of(context) where MaterialApp is created. That context is above the theme, so you get the default. Read the theme from widgets below MaterialApp.
Using ThemeData.dark() for the dark theme. It ignores your color scheme. Pass a dark ColorScheme as above.
Setting colors with copyWith on a generated scheme. Overriding primary without onPrimary can leave you with unreadable button text. Change them in pairs, or change the seed.
Using the old theme types. ThemeData takes CardThemeData, not CardTheme, and DialogThemeData, not DialogTheme. Tutorials from before these changes won’t compile against current Flutter.
Using Color.withOpacity. It’s deprecated. Use color.withValues(alpha: 0.3).
Styling each screen by hand. If you’re writing style: ... on every button, that belongs in filledButtonTheme. Per-widget styles are for genuine exceptions.
Setting scaffoldBackgroundColor to a fixed color. It applies to both themes, so one of them looks wrong. If you need a specific background, pass surface: to fromSeed for each brightness.