By day 21 of building production Flutter apps, you've probably hardcoded a few colors and spacing values directly into your widgets. I did the same until I realized how fragile that approach becomes when a designer changes the brand palette or your app needs to support multiple themes. ThemeExtension for custom tokens is Flutter's answer to centralizing brand colors, spacing, typography scales, and any other design system values you want to reuse across your entire codebase without scattering magic numbers everywhere.
Why custom design tokens matter
Design tokens are the single source of truth for your visual language. In my experience shipping e-commerce platforms and SaaS products, I've seen teams struggle because colors lived in dozens of places—some in Material theme, some in constants files, some hardcoded in widget builds. This fragmentation made rebrand cycles painful and theme switching nearly impossible.
Consider a real scenario: you're building a ride-hailing app with two themes: a day theme with light backgrounds and a night theme with dark backgrounds. Beyond colors, you have spacing tokens (8dp, 16dp, 24dp, 32dp), border radius scales, shadow depths, and animation durations tied to your brand identity. Scattering these across your codebase means:
- Designers can't trust that the implementation matches specs.
- Theme switching requires hunting through widget code.
- Adding a new theme variant requires changes in 50+ files.
- Junior developers don't know where to find the "correct" color for a button.
ThemeExtension solves this by letting you define a strongly-typed, theme-aware token system that integrates seamlessly with Flutter's built-in Theme system.
Define custom design tokens
Start by creating a class that extends ThemeExtension. I'll build an example from a travel booking app I shipped last year, which needed custom colors, spacing, and corner radius tokens.
import 'package:flutter/material.dart';
class AppTokens extends ThemeExtension {
final Color primaryBrand;
final Color secondaryBrand;
final Color errorAccent;
final Color successAccent;
final Color warningAccent;
final Color surfacePrimary;
final Color surfaceSecondary;
final Color textPrimary;
final Color textSecondary;
final Color textHint;
final Color dividerColor;
final double spacing4;
final double spacing8;
final double spacing12;
final double spacing16;
final double spacing24;
final double spacing32;
final double radiusSmall;
final double radiusMedium;
final double radiusLarge;
final Duration transitionFast;
final Duration transitionNormal;
AppTokens({
required this.primaryBrand,
required this.secondaryBrand,
required this.errorAccent,
required this.successAccent,
required this.warningAccent,
required this.surfacePrimary,
required this.surfaceSecondary,
required this.textPrimary,
required this.textSecondary,
required this.textHint,
required this.dividerColor,
required this.spacing4,
required this.spacing8,
required this.spacing12,
required this.spacing16,
required this.spacing24,
required this.spacing32,
required this.radiusSmall,
required this.radiusMedium,
required this.radiusLarge,
required this.transitionFast,
required this.transitionNormal,
});
// Light theme variant
static AppTokens light() {
return AppTokens(
primaryBrand: const Color(0xFF1976D2),
secondaryBrand: const Color(0xFF00BCD4),
errorAccent: const Color(0xFFD32F2F),
successAccent: const Color(0xFF388E3C),
warningAccent: const Color(0xFFF57C00),
surfacePrimary: const Color(0xFFFFFFFF),
surfaceSecondary: const Color(0xFFF5F5F5),
textPrimary: const Color(0xFF212121),
textSecondary: const Color(0xFF757575),
textHint: const Color(0xFFBDBDBD),
dividerColor: const Color(0xFFE0E0E0),
spacing4: 4.0,
spacing8: 8.0,
spacing12: 12.0,
spacing16: 16.0,
spacing24: 24.0,
spacing32: 32.0,
radiusSmall: 4.0,
radiusMedium: 8.0,
radiusLarge: 12.0,
transitionFast: const Duration(milliseconds: 150),
transitionNormal: const Duration(milliseconds: 300),
);
}
// Dark theme variant
static AppTokens dark() {
return AppTokens(
primaryBrand: const Color(0xFF64B5F6),
secondaryBrand: const Color(0xFF4DD0E1),
errorAccent: const Color(0xFFEF5350),
successAccent: const Color(0xFF66BB6A),
warningAccent: const Color(0xFFFFB74D),
surfacePrimary: const Color(0xFF121212),
surfaceSecondary: const Color(0xFF1E1E1E),
textPrimary: const Color(0xFFFFFFFF),
textSecondary: const Color(0xFFB0B0B0),
textHint: const Color(0xFF616161),
dividerColor: const Color(0xFF303030),
spacing4: 4.0,
spacing8: 8.0,
spacing12: 12.0,
spacing16: 16.0,
spacing24: 24.0,
spacing32: 32.0,
radiusSmall: 4.0,
radiusMedium: 8.0,
radiusLarge: 12.0,
transitionFast: const Duration(milliseconds: 150),
transitionNormal: const Duration(milliseconds: 300),
);
}
@override
ThemeExtension copyWith({
Color? primaryBrand,
Color? secondaryBrand,
Color? errorAccent,
Color? successAccent,
Color? warningAccent,
Color? surfacePrimary,
Color? surfaceSecondary,
Color? textPrimary,
Color? textSecondary,
Color? textHint,
Color? dividerColor,
double? spacing4,
double? spacing8,
double? spacing12,
double? spacing16,
double? spacing24,
double? spacing32,
double? radiusSmall,
double? radiusMedium,
double? radiusLarge,
Duration? transitionFast,
Duration? transitionNormal,
}) {
return AppTokens(
primaryBrand: primaryBrand ?? this.primaryBrand,
secondaryBrand: secondaryBrand ?? this.secondaryBrand,
errorAccent: errorAccent ?? this.errorAccent,
successAccent: successAccent ?? this.successAccent,
warningAccent: warningAccent ?? this.warningAccent,
surfacePrimary: surfacePrimary ?? this.surfacePrimary,
surfaceSecondary: surfaceSecondary ?? this.surfaceSecondary,
textPrimary: textPrimary ?? this.textPrimary,
textSecondary: textSecondary ?? this.textSecondary,
textHint: textHint ?? this.textHint,
dividerColor: dividerColor ?? this.dividerColor,
spacing4: spacing4 ?? this.spacing4,
spacing8: spacing8 ?? this.spacing8,
spacing12: spacing12 ?? this.spacing12,
spacing16: spacing16 ?? this.spacing16,
spacing24: spacing24 ?? this.spacing24,
spacing32: spacing32 ?? this.spacing32,
radiusSmall: radiusSmall ?? this.radiusSmall,
radiusMedium: radiusMedium ?? this.radiusMedium,
radiusLarge: radiusLarge ?? this.radiusLarge,
transitionFast: transitionFast ?? this.transitionFast,
transitionNormal: transitionNormal ?? this.transitionNormal,
);
}
@override
ThemeExtension lerp(ThemeExtension? other, double t) {
if (other is! AppTokens) return this;
return AppTokens(
primaryBrand: Color.lerp(primaryBrand, other.primaryBrand, t)!,
secondaryBrand: Color.lerp(secondaryBrand, other.secondaryBrand, t)!,
errorAccent: Color.lerp(errorAccent, other.errorAccent, t)!,
successAccent: Color.lerp(successAccent, other.successAccent, t)!,
warningAccent: Color.lerp(warningAccent, other.warningAccent, t)!,
surfacePrimary: Color.lerp(surfacePrimary, other.surfacePrimary, t)!,
surfaceSecondary: Color.lerp(surfaceSecondary, other.surfaceSecondary, t)!,
textPrimary: Color.lerp(textPrimary, other.textPrimary, t)!,
textSecondary: Color.lerp(textSecondary, other.textSecondary, t)!,
textHint: Color.lerp(textHint, other.textHint, t)!,
dividerColor: Color.lerp(dividerColor, other.dividerColor, t)!,
spacing4: spacing4,
spacing8: spacing8,
spacing12: spacing12,
spacing16: spacing16,
spacing24: spacing24,
spacing32: spacing32,
radiusSmall: radiusSmall,
radiusMedium: radiusMedium,
radiusLarge: radiusLarge,
transitionFast: transitionFast,
transitionNormal: transitionNormal,
);
}
}
This is verbose, but it's a one-time investment. The key insight is that you're defining tokens once and making them discoverable through the theme system. Notice I've separated semantic tokens (like textPrimary for primary text) from component-specific colors—this makes switching themes effortless because a single token change propagates everywhere it's used.
Access via Theme.of(context).extension
Now integrate AppTokens into your app's material theme:
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.light,
extensions: [AppTokens.light()],
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
extensions: [AppTokens.dark()],
),
themeMode: ThemeMode.system,
home: const HomePage(),
);
}
}
Inside any widget, retrieve tokens via Theme.of(context).extension(). I typically create a small helper extension on BuildContext to keep code clean:
extension ThemeExtensionHelper on BuildContext {
AppTokens get tokens {
return Theme.of(this).extension()!;
}
}
Now you can use it throughout your widgets:
class BookingCard extends StatelessWidget {
final String destination;
final String date;
const BookingCard({
required this.destination,
required this.date,
});
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
return Container(
margin: EdgeInsets.all(tokens.spacing16),
padding: EdgeInsets.all(tokens.spacing16),
decoration: BoxDecoration(
color: tokens.surfacePrimary,
borderRadius: BorderRadius.circular(tokens.radiusMedium),
border: Border.all(color: tokens.dividerColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
destination,
style: TextStyle(
color: tokens.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
SizedBox(height: tokens.spacing8),
Text(
date,
style: TextStyle(
color: tokens.textSecondary,
fontSize: 14,
),
),
SizedBox(height: tokens.spacing16),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: tokens.primaryBrand,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.radiusSmall),
),
),
onPressed: () {},
child: Text(
'Book Now',
style: TextStyle(color: tokens.surfacePrimary),
),
),
],
),
);
}
}
The widget is now completely decoupled from hardcoded colors. Change the token values in AppTokens, and the entire app follows suit.
Supports lerp for theme animation
The lerp method I implemented in the AppTokens class enables smooth animations when switching themes. Flutter automatically interpolates between the current and new theme extensions over the specified duration. This is crucial for a polished user experience—I've shipped apps where theme transitions feel janky because colors jump instantly instead of fading.
Here's how to animate theme changes in practice:
class ThemeToggleButton extends StatefulWidget {
const ThemeToggleButton({Key? key}) : super(key: key);
@override
State createState() => _ThemeToggleButtonState();
}
class _ThemeToggleButtonState extends State {
late ThemeMode _themeMode;
@override
void initState() {
super.initState();
_themeMode = ThemeMode.system;
}
void _toggleTheme() {
setState(() {
_themeMode = _themeMode == ThemeMode.light
? ThemeMode.dark
: ThemeMode.light;
});
final app = context.findAncestorWidgetOfExactType();
app?.changeTheme(_themeMode);
}
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
return FloatingActionButton(
backgroundColor: tokens.primaryBrand,
onPressed: _toggleTheme,
child: Icon(
_themeMode == ThemeMode.light ? Icons.dark_mode : Icons.light_mode,
color: tokens.surfacePrimary,
),
);
}
}
To support dynamic theme switching, wrap your MaterialApp in a state management solution (I typically use Riverpod or Provider):
class MyApp extends ConsumerWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeProvider);
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.light,
extensions: [AppTokens.light()],
),
darkTheme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
extensions: [AppTokens.dark()],
),
themeMode: themeMode,
home: const HomePage(),
);
}
}
The lerp method ensures colors smoothly transition. Because you defined lerp to interpolate Color values, spacing and other non-animatable tokens remain constant (which is correct—you don't want padding to animate).
Keeps widgets free of hardcoded colors
This is the real payoff. On the CRM platform I built, we had over 400 widgets. Before using ThemeExtension, redesigns were a nightmare—designers would send updated color specs and I'd spend hours grep-ing for Color(0xFF1976D2) scattered throughout the codebase. With tokens, I changed one line in AppTokens.light() and the entire app was updated instantly.
More importantly, junior developers on the team didn't need to ask "what color should this button be?" They could just write:
FloatingActionButton(
backgroundColor: context.tokens.primaryBrand,
onPressed: onPressed,
child: child,
)
No ambiguity, no brand color drift. The design system became self-documenting and enforceable.
Common mistakes
- Forgetting to implement
copyWithorlerp: If you skip these, theme changes will fail or animations won't work. Implement them first, even if you think you won't need them yet—you will. - Using
context.tokensin top-level const widgets: If your widget is aconstconstructor, you can't callcontext.tokensin the build method. Either make the widget non-const or pass tokens as parameters. - Mixing Material colors and custom tokens: Don't use both. If you're using
ThemeExtension, define all your colors there. Mixed approaches create confusion and make theme switching unreliable. - Defining too many tokens: Start with colors, spacing, and radius. Adding tokens for every possible property bloats your extension. Keep it focused on design system essentials.
- Not versioning token changes: If your tokens change frequently (e.g., based on API responses or user preferences), consider adding a version field so old cached themes don't break.
Key takeaways
- ThemeExtension centralizes brand colors, spacing, and other design tokens in a single, strongly-typed class that integrates with Flutter's theme system.
- Define both light and dark variants as static factory methods, making theme switching simple and predictable.
- Implement
copyWithfor testing and variations, andlerpfor smooth theme animations. - Access tokens via
context.tokens(using a helper extension) throughout your widgets, eliminating hardcoded color values. - This pattern scales beautifully: I've shipped apps with 10+ token types and 5+ themes without maintenance headaches.
- Theme changes propagate globally without touching individual widget code, making redesigns and brand updates painless.






Comments
Loading comments…