Answer: What Are Design Tokens in Flutter?
Design tokens are named constants for colors, spacing, typography, border radii, and other visual properties. Instead of hardcoding Colors.blue or EdgeInsets.all(16) throughout your app, you define tokens like AppTheme.primaryColor and AppSpacing.md in one place. Change a token once, and the entire app updates — across light mode, dark mode, iOS, and Android simultaneously.
Flutter's Material 3 support (enabled with useMaterial3: true) provides a sophisticated theming engine built around ColorScheme, TextTheme, and component themes. This guide shows you how to use it properly and extend it with custom tokens for a production-ready design system.
Step 1: Enable Material 3 and Create a Color Scheme
The foundation of Material 3 theming is ColorScheme.fromSeed(). It takes a single seed color and generates a complete, harmonious color palette with 29 color roles — primary, secondary, tertiary, surface, error, and their container variants.
import 'package:flutter/material.dart';
class AppTheme {
// Your brand seed color
static const Color seedColor = Color(0xFF6750A4);
// Light theme
static ThemeData get lightTheme {
final colorScheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.light,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
);
}
// Dark theme
static ThemeData get darkTheme {
final colorScheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
);
}
}
// In your MaterialApp
MaterialApp(
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system, // Follow system setting
home: const HomeScreen(),
)With just a single seed color, you get a complete light and dark theme that follows Material 3 guidelines. Every Material component — buttons, cards, dialogs, navigation bars — automatically adapts to your color scheme.
Step 2: Customize the Color Scheme
While fromSeed() is great for prototyping, production apps usually need more control. You can override specific color roles while keeping the generated harmony for the rest:
static ThemeData get lightTheme {
final baseScheme = ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.light,
);
// Override specific colors while keeping harmony
final colorScheme = baseScheme.copyWith(
primary: const Color(0xFF6750A4),
onPrimary: Colors.white,
primaryContainer: const Color(0xFFEADDFF),
secondary: const Color(0xFF625B71),
tertiary: const Color(0xFF7D5260),
error: const Color(0xFFBA1A1A),
surface: const Color(0xFFFFFBFE),
onSurface: const Color(0xFF1C1B1F),
surfaceContainerHighest: const Color(0xFFE6E0EC),
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
scaffoldBackgroundColor: colorScheme.surface,
);
}You can also create a fully custom color scheme without fromSeed() by constructing the ColorScheme directly. This gives you pixel-perfect control when your designer provides exact hex values for every role.
Step 3: Typography Setup
Material 3 defines five typography scale categories — Display, Headline, Title, Body, and Label — each with Large, Medium, and Small variants. Here is how to set up custom typography:
import 'package:google_fonts/google_fonts.dart';
class AppTheme {
static TextTheme get _textTheme {
return TextTheme(
// Display — largest, used for hero text
displayLarge: GoogleFonts.inter(
fontSize: 57,
fontWeight: FontWeight.w400,
letterSpacing: -0.25,
),
displayMedium: GoogleFonts.inter(
fontSize: 45,
fontWeight: FontWeight.w400,
),
displaySmall: GoogleFonts.inter(
fontSize: 36,
fontWeight: FontWeight.w400,
),
// Headline — section headers
headlineLarge: GoogleFonts.inter(
fontSize: 32,
fontWeight: FontWeight.w600,
),
headlineMedium: GoogleFonts.inter(
fontSize: 28,
fontWeight: FontWeight.w600,
),
headlineSmall: GoogleFonts.inter(
fontSize: 24,
fontWeight: FontWeight.w600,
),
// Title — card titles, app bar
titleLarge: GoogleFonts.inter(
fontSize: 22,
fontWeight: FontWeight.w500,
),
titleMedium: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w500,
letterSpacing: 0.15,
),
titleSmall: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
// Body — main content text
bodyLarge: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.5,
),
bodyMedium: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.25,
),
bodySmall: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.4,
),
// Label — buttons, captions, chips
labelLarge: GoogleFonts.inter(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
labelMedium: GoogleFonts.inter(
fontSize: 12,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
),
labelSmall: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w500,
letterSpacing: 0.5,
),
);
}
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: seedColor,
),
textTheme: _textTheme,
);
}
}Using Typography in Widgets
// Always reference the theme — never hardcode
Text(
'Section Title',
style: Theme.of(context).textTheme.headlineMedium,
)
// Or with color overrides
Text(
'Subtitle text',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)Step 4: Custom Design Tokens with Theme Extensions
Material 3's built-in color roles and text styles cover the basics, but production apps need custom tokens for spacing, border radii, gradients, shadows, and app-specific values. Flutter's ThemeExtension lets you attach custom data to the theme:
// Define your custom tokens
class AppTokens extends ThemeExtension<AppTokens> {
final double spacingXs;
final double spacingSm;
final double spacingMd;
final double spacingLg;
final double spacingXl;
final double radiusSm;
final double radiusMd;
final double radiusLg;
final double radiusFull;
final List<BoxShadow> shadowSm;
final List<BoxShadow> shadowMd;
final LinearGradient primaryGradient;
const AppTokens({
required this.spacingXs,
required this.spacingSm,
required this.spacingMd,
required this.spacingLg,
required this.spacingXl,
required this.radiusSm,
required this.radiusMd,
required this.radiusLg,
required this.radiusFull,
required this.shadowSm,
required this.shadowMd,
required this.primaryGradient,
});
// Light theme tokens
static const light = AppTokens(
spacingXs: 4,
spacingSm: 8,
spacingMd: 16,
spacingLg: 24,
spacingXl: 32,
radiusSm: 8,
radiusMd: 12,
radiusLg: 16,
radiusFull: 999,
shadowSm: [
BoxShadow(
color: Color(0x1A000000),
blurRadius: 4,
offset: Offset(0, 2),
),
],
shadowMd: [
BoxShadow(
color: Color(0x26000000),
blurRadius: 12,
offset: Offset(0, 4),
),
],
primaryGradient: LinearGradient(
colors: [Color(0xFF6750A4), Color(0xFF7D5260)],
),
);
// Dark theme tokens (spacing stays the same,
// visual tokens adapt)
static const dark = AppTokens(
spacingXs: 4,
spacingSm: 8,
spacingMd: 16,
spacingLg: 24,
spacingXl: 32,
radiusSm: 8,
radiusMd: 12,
radiusLg: 16,
radiusFull: 999,
shadowSm: [
BoxShadow(
color: Color(0x33000000),
blurRadius: 4,
offset: Offset(0, 2),
),
],
shadowMd: [
BoxShadow(
color: Color(0x4D000000),
blurRadius: 12,
offset: Offset(0, 4),
),
],
primaryGradient: LinearGradient(
colors: [Color(0xFFD0BCFF), Color(0xFFEFB8C8)],
),
);
@override
AppTokens copyWith({/* ... */}) {
return AppTokens(
spacingXs: spacingXs,
spacingSm: spacingSm,
spacingMd: spacingMd,
spacingLg: spacingLg,
spacingXl: spacingXl,
radiusSm: radiusSm,
radiusMd: radiusMd,
radiusLg: radiusLg,
radiusFull: radiusFull,
shadowSm: shadowSm,
shadowMd: shadowMd,
primaryGradient: primaryGradient,
);
}
@override
AppTokens lerp(AppTokens? other, double t) {
if (other == null) return this;
return copyWith();
}
}Register Tokens with the Theme
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: seedColor,
),
textTheme: _textTheme,
extensions: const [AppTokens.light],
);
}
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
),
textTheme: _textTheme,
extensions: const [AppTokens.dark],
);
}Access Tokens in Widgets
// Create a convenience extension
extension ThemeExtensions on BuildContext {
AppTokens get tokens =>
Theme.of(this).extension<AppTokens>()!;
ColorScheme get colors =>
Theme.of(this).colorScheme;
TextTheme get textStyles =>
Theme.of(this).textTheme;
}
// Now use it anywhere — clean and concise
class FeatureCard extends StatelessWidget {
final String title;
final String description;
const FeatureCard({
super.key,
required this.title,
required this.description,
});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(context.tokens.spacingMd),
decoration: BoxDecoration(
color: context.colors.surfaceContainerLow,
borderRadius: BorderRadius.circular(
context.tokens.radiusMd,
),
boxShadow: context.tokens.shadowSm,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: context.textStyles.titleMedium,
),
SizedBox(height: context.tokens.spacingSm),
Text(
description,
style: context.textStyles.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
);
}
}Step 5: Dark Mode Implementation
With the token system above, dark mode comes nearly for free. The ColorScheme automatically generates dark variants, and your custom tokens provide dark-specific values. But there are some nuances to get it right:
Theme Mode Switching
// A BLoC to manage theme preference
class ThemeBloc extends Bloc<ThemeEvent, ThemeState> {
final SharedPreferences _prefs;
ThemeBloc(this._prefs)
: super(ThemeState(
themeMode: _loadSavedTheme(_prefs),
)) {
on<ToggleTheme>(_onToggleTheme);
on<SetThemeMode>(_onSetThemeMode);
}
static ThemeMode _loadSavedTheme(
SharedPreferences prefs,
) {
final saved = prefs.getString('themeMode');
switch (saved) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
void _onToggleTheme(
ToggleTheme event,
Emitter<ThemeState> emit,
) {
final newMode = state.themeMode == ThemeMode.dark
? ThemeMode.light
: ThemeMode.dark;
_prefs.setString('themeMode', newMode.name);
emit(ThemeState(themeMode: newMode));
}
void _onSetThemeMode(
SetThemeMode event,
Emitter<ThemeState> emit,
) {
_prefs.setString('themeMode', event.mode.name);
emit(ThemeState(themeMode: event.mode));
}
}
// In MaterialApp
BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, state) {
return MaterialApp(
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: state.themeMode,
home: const HomeScreen(),
);
},
)Dark Mode Best Practices
- Never use pure black (
#000000) as a background. UsecolorScheme.surfacewhich provides a slightly elevated dark gray. Pure black creates harsh contrast and looks unnatural. - Reduce elevation in dark mode. Material 3 uses tonal elevation (surface tint) instead of shadows in dark mode. This is handled automatically when you use
colorScheme.surfaceContainerLow/High. - Test with both modes during development. Add theme toggle to your debug menu so you can switch instantly.
- Use semantic colors, not hardcoded values.
context.colors.onSurfaceadapts to both modes.Colors.blackdoes not.
Step 6: Dynamic Color on Android
Android 12+ supports Dynamic Color — the system generates a color scheme from the user's wallpaper. Your app can adopt this palette for a deeply personalized feel. The dynamic_color package makes this easy:
// pubspec.yaml
dependencies:
dynamic_color: ^1.7.0
// In your app
import 'package:dynamic_color/dynamic_color.dart';
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return DynamicColorBuilder(
builder: (
ColorScheme? lightDynamic,
ColorScheme? darkDynamic,
) {
// Use dynamic colors if available,
// fall back to seed-based scheme
final lightScheme = lightDynamic ??
ColorScheme.fromSeed(
seedColor: AppTheme.seedColor,
);
final darkScheme = darkDynamic ??
ColorScheme.fromSeed(
seedColor: AppTheme.seedColor,
brightness: Brightness.dark,
);
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
colorScheme: lightScheme,
textTheme: AppTheme.textTheme,
extensions: const [AppTokens.light],
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: darkScheme,
textTheme: AppTheme.textTheme,
extensions: const [AppTokens.dark],
),
themeMode: ThemeMode.system,
home: const HomeScreen(),
);
},
);
}
}On Android 12+ devices, your app automatically matches the user's wallpaper colors. On older Android versions and iOS, it falls back to your seed-based color scheme. This is the kind of platform-aware polish that makes your app feel native.
Step 7: Component-Level Theme Customization
Material 3 lets you customize every built-in component through theme data. Here are the most commonly customized components:
static ThemeData get lightTheme {
final colorScheme = ColorScheme.fromSeed(
seedColor: seedColor,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
textTheme: _textTheme,
extensions: const [AppTokens.light],
// App Bar
appBarTheme: AppBarTheme(
centerTitle: true,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
elevation: 0,
scrolledUnderElevation: 1,
),
// Cards
cardTheme: CardTheme(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
color: colorScheme.surfaceContainerLow,
),
// Filled Buttons
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
// Input Fields
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorScheme.surfaceContainerHighest
.withOpacity(0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
// Bottom Navigation
navigationBarTheme: NavigationBarThemeData(
backgroundColor: colorScheme.surface,
indicatorColor: colorScheme.secondaryContainer,
labelTextStyle: WidgetStateProperty.all(
const TextStyle(fontSize: 12),
),
),
// Dialogs
dialogTheme: DialogTheme(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
),
);
}Putting It All Together: The Design Token File
In a production app, all of this lives in a single app_theme.dart file. Here is the recommended structure:
// lib/core/theme/app_theme.dart
// 1. Seed color and brand constants
// 2. AppTokens ThemeExtension class
// 3. TextTheme definition
// 4. Light theme ThemeData
// 5. Dark theme ThemeData
// 6. BuildContext extension for easy access
// Total: ~200 lines for a complete design system
// Change the seed color -> entire app updates
// Change a token -> every widget adapts
// Toggle dark mode -> everything worksThis is exactly how The Flutter Kit structures its design system. One file, complete control, both modes, both platforms. You change the seed color and a handful of tokens, and the entire app takes on your brand identity.
Common Mistakes to Avoid
- Hardcoding colors. Every time you write
Color(0xFF...)in a widget, you are creating a design debt. Usecontext.colors.primaryorcontext.tokens.spacingMdinstead. - Forgetting to set
useMaterial3: true. Without this flag, Flutter uses Material 2 styling. Material 3 provides better defaults, more color roles, and updated component designs. - Ignoring surface container variants. Material 3 introduced
surfaceContainerLow,surfaceContainer, andsurfaceContainerHighfor layered surfaces. Use them instead of manually adjusting opacity. - Not testing on both platforms. Material 3 components render slightly differently on iOS vs Android. Test your theme on both simulators to catch visual discrepancies.
- Over-customizing component themes. Material 3's defaults are well-designed. Only override component themes when your design requires it. Let the color scheme do the heavy lifting.
Get a Complete Design System Out of the Box
The Flutter Kit includes a production-ready Material 3 design system with all the patterns described in this guide — ColorScheme.fromSeed, custom tokens with ThemeExtension, typography, dark mode, dynamic color support, and component-level customization. You change the seed color in one file, and the entire app — onboarding, paywalls, settings, AI chat — adapts to your brand.
Check out the features page for the full design system details, or grab the kit at checkout for $69 one-time.