# What You’ll Build#
A scalable, feature-proof Flutter design system theming setup using Material 3, with:
- A token-based architecture for colors, typography, spacing, and radii
- Material 3
ColorSchemeand component theming - Dynamic color on supported Android versions with safe fallbacks
- A predictable dark mode strategy
- Designer-to-developer handoff practices that reduce rework
If you’re organizing a growing app by features, align this guide with a feature-first structure like Flutter app architecture: Clean Architecture + feature-first. The theme should be a shared foundation, not something each feature reinvents.
# Why This Matters in Real Apps#
Theming is not just “make it look nice.” It affects velocity and bugs:
- UI inconsistency scales linearly with team size if there is no shared token layer. Two developers can produce two “nearly identical” buttons that differ in padding, radius, or color and you won’t notice until late QA.
- Flutter makes it easy to override style locally, which is convenient short-term and expensive long-term.
- Material 3 is more opinionated. If you commit to it, you get consistent defaults, better accessibility patterns, and a component theming model that reduces per-widget boilerplate.
🎯 Key Takeaway: A scalable theme is a product of architecture: tokens plus component themes plus enforcement.
# Prerequisites#
| Requirement | Version | Notes |
|---|---|---|
| Flutter | 3.22+ | Material 3 support is mature; update to the latest stable if possible |
| Dart | 3.4+ | For modern language features and better analyzer support |
| Material 3 | Enabled | Set useMaterial3: true in ThemeData |
| Android | 12+ | Needed for dynamic color; still provide fallback schemes |
| Basic design system artifacts | — | Color palette, typographic scale, spacing scale from design |
# Theme Architecture: Tokens First, ThemeData Second#
A good Flutter theme architecture separates concerns:
- 1Tokens define raw values and semantic meanings.
- 2Theme composition builds
ThemeDatafrom tokens. - 3Component theming standardizes widgets without local overrides.
- 4Consumption in features is read-only: features use theme, never define it.
Recommended Folder Structure#
For a medium to large app, put the design system in its own package inside your repo (or a separate package if you share across apps).
| Layer | Example path | Responsibility |
|---|---|---|
| Tokens | packages/ui_kit/lib/src/tokens/ | Colors, typography, spacing, radii, elevation |
| Theme builders | packages/ui_kit/lib/src/theme/ | Build ThemeData for light and dark |
| Components | packages/ui_kit/lib/src/components/ | Buttons, inputs, app bars, cards |
| Public API | packages/ui_kit/lib/ui_kit.dart | Export stable interfaces only |
This keeps features clean and prevents circular dependencies when your app grows.
💡 Tip: Make tokens immutable and avoid pulling
BuildContextinto token classes. Tokens should be pure data, easy to test, and safe to reuse in background isolates if needed.
# Step 1: Define Design Tokens (Colors, Spacing, Radius)#
Tokens are the contract between design and code. Treat them as a stable API.
Color Tokens: Brand and Semantics#
You want two levels:
- Palette tokens: raw colors like brand blue or neutral gray ramp
- Semantic tokens: meaning-based colors like
success,warning,onSurfaceMuted
In Material 3, the semantic layer is mostly expressed via ColorScheme. Still, it’s useful to keep explicit semantic tokens for non-Material concepts, such as charts or status badges.
// packages/ui_kit/lib/src/tokens/app_palette.dart
import 'package:flutter/material.dart';
class AppPalette {
AppPalette._();
// Brand
static const brandBlue = Color(0xFF2563EB);
static const brandBlueDark = Color(0xFF1D4ED8);
// Neutrals
static const neutral0 = Color(0xFFFFFFFF);
static const neutral900 = Color(0xFF0B1220);
// Status
static const success = Color(0xFF16A34A);
static const warning = Color(0xFFF59E0B);
static const danger = Color(0xFFDC2626);
}// packages/ui_kit/lib/src/tokens/app_semantic_colors.dart
import 'package:flutter/material.dart';
import '../tokens/app_palette.dart';
class AppSemanticColors {
final Color success;
final Color warning;
final Color danger;
const AppSemanticColors({
required this.success,
required this.warning,
required this.danger,
});
static const light = AppSemanticColors(
success: AppPalette.success,
warning: AppPalette.warning,
danger: AppPalette.danger,
);
static const dark = AppSemanticColors(
success: AppPalette.success,
warning: AppPalette.warning,
danger: AppPalette.danger,
);
}Spacing Tokens: Use a Scale, Not Random Numbers#
Spacing drift is one of the fastest ways to lose consistency. Use a fixed scale, usually 4-based or 8-based. Material guidelines commonly align with 4 dp increments.
// packages/ui_kit/lib/src/tokens/app_spacing.dart
class AppSpacing {
AppSpacing._();
static const double xs = 4;
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 24;
static const double xxl = 32;
static const double xxxl = 40;
}Radius Tokens: Keep the Set Small#
Too many radii equals visual noise. Pick 3 to 5.
// packages/ui_kit/lib/src/tokens/app_radii.dart
class AppRadii {
AppRadii._();
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double pill = 999;
}⚠️ Warning: Don’t expose raw
BorderRadius.circular(13)in feature code. If a radius is not a token, you’ll end up with dozens of unique corner roundings and an inconsistent “feel.”
# Step 2: Typography System (Material 3 + Your Scale)#
Typography should be both systematic and semantic.
- Systematic means: defined scale, consistent sizes and line heights.
- Semantic means: usage intent like
title,body,label, rather than “18 bold.”
Define a Type Scale#
Material 3 TextTheme includes styles like displayLarge, headlineMedium, titleLarge, bodyMedium, labelLarge. Map your design scale to these.
// packages/ui_kit/lib/src/tokens/app_typography.dart
import 'package:flutter/material.dart';
class AppTypography {
AppTypography._();
static TextTheme textTheme({
required String fontFamily,
required Brightness brightness,
}) {
final base = brightness == Brightness.dark
? Typography.material2021().white
: Typography.material2021().black;
return base.copyWith(
titleLarge: base.titleLarge?.copyWith(
fontFamily: fontFamily,
fontWeight: FontWeight.w700,
fontSize: 22,
height: 28 / 22,
),
bodyMedium: base.bodyMedium?.copyWith(
fontFamily: fontFamily,
fontWeight: FontWeight.w400,
fontSize: 16,
height: 24 / 16,
),
labelLarge: base.labelLarge?.copyWith(
fontFamily: fontFamily,
fontWeight: FontWeight.w600,
fontSize: 14,
height: 20 / 14,
letterSpacing: 0.2,
),
);
}
}Enforce Semantic Usage#
Avoid creating styles ad-hoc inside features. Instead, use theme styles:
Text(
'Payment method',
style: Theme.of(context).textTheme.titleLarge,
)If you need a specific variant often (for example “muted body”), create a tiny extension, not a new TextStyle everywhere.
// packages/ui_kit/lib/src/theme/text_theme_ext.dart
import 'package:flutter/material.dart';
extension AppTextThemeX on TextTheme {
TextStyle? get bodyMuted => bodyMedium?.copyWith(
color: color?.withValues(alpha: 0.72),
);
}Use it like this:
Text(
'Updated 2 hours ago',
style: Theme.of(context).textTheme.bodyMuted,
)ℹ️ Note: Flutter’s
TextThemedoesn’t carry color by default. Many teams rely onDefaultTextStyleandColorScheme.onSurface. If you usecopyWith(color: ...)patterns, ensure they remain contrast-safe in dark mode.
# Step 3: Material 3 ColorScheme, Light and Dark#
Material 3 theming in Flutter should be driven by ColorScheme plus component themes. Start with a brand seed to generate consistent tonal palettes.
Build ColorSchemes#
// packages/ui_kit/lib/src/theme/app_color_schemes.dart
import 'package:flutter/material.dart';
import '../tokens/app_palette.dart';
class AppColorSchemes {
AppColorSchemes._();
static final ColorScheme light = ColorScheme.fromSeed(
seedColor: AppPalette.brandBlue,
brightness: Brightness.light,
);
static final ColorScheme dark = ColorScheme.fromSeed(
seedColor: AppPalette.brandBlueDark,
brightness: Brightness.dark,
);
}This gives you a full scheme: primary, secondary, surface, error, and all on-colors.
Brand Overrides for Critical Roles#
Most real brands need a few explicit constraints. Override carefully to avoid breaking contrast.
final light = AppColorSchemes.light.copyWith(
primary: AppPalette.brandBlue,
);
final dark = AppColorSchemes.dark.copyWith(
primary: AppPalette.brandBlueDark,
);A practical rule: override primary and maybe tertiary, then let Material compute the rest unless your design system explicitly defines each role.
# Step 4: Dynamic Color on Android with Safe Fallbacks#
Dynamic color adapts your app’s scheme to the user’s wallpaper on Android 12+. It can improve perceived “native-ness” and reduce visual friction.
The goal is not “dynamic everywhere.” The goal is “dynamic when available, predictable otherwise.”
Implementation Pattern#
If you use the dynamic_color package, you can fetch dynamic schemes and fall back.
// app/lib/app.dart
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:ui_kit/ui_kit.dart';
class AppRoot extends StatelessWidget {
const AppRoot({super.key});
@override
Widget build(BuildContext context) {
return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
final theme = AppTheme.build(
lightDynamic: lightDynamic,
darkDynamic: darkDynamic,
);
return MaterialApp(
theme: theme.light,
darkTheme: theme.dark,
themeMode: ThemeMode.system,
home: const Placeholder(),
);
},
);
}
}And in the UI kit:
// packages/ui_kit/lib/src/theme/app_theme.dart
import 'package:flutter/material.dart';
import '../tokens/app_semantic_colors.dart';
import 'app_color_schemes.dart';
import '../tokens/app_typography.dart';
class AppThemeBundle {
final ThemeData light;
final ThemeData dark;
const AppThemeBundle({required this.light, required this.dark});
}
class AppTheme {
AppTheme._();
static AppThemeBundle build({
ColorScheme? lightDynamic,
ColorScheme? darkDynamic,
}) {
final lightScheme = lightDynamic ?? AppColorSchemes.light;
final darkScheme = darkDynamic ?? AppColorSchemes.dark;
return AppThemeBundle(
light: _theme(brightness: Brightness.light, scheme: lightScheme),
dark: _theme(brightness: Brightness.dark, scheme: darkScheme),
);
}
static ThemeData _theme({
required Brightness brightness,
required ColorScheme scheme,
}) {
final textTheme = AppTypography.textTheme(
fontFamily: 'Inter',
brightness: brightness,
);
return ThemeData(
useMaterial3: true,
brightness: brightness,
colorScheme: scheme,
textTheme: textTheme,
extensions: <ThemeExtension<dynamic>>[
_SemanticColorsExt(
colors: brightness == Brightness.dark
? AppSemanticColors.dark
: AppSemanticColors.light,
),
],
);
}
}
class _SemanticColorsExt extends ThemeExtension<_SemanticColorsExt> {
final AppSemanticColors colors;
const _SemanticColorsExt({required this.colors});
@override
_SemanticColorsExt copyWith({AppSemanticColors? colors}) =>
_SemanticColorsExt(colors: colors ?? this.colors);
@override
_SemanticColorsExt lerp(ThemeExtension<_SemanticColorsExt>? other, double t) =>
this;
}This pattern keeps dynamic color contained to theme composition. Features keep reading ColorScheme and semantic tokens via theme extensions.
💡 Tip: Add a debug-only “force dynamic” toggle and a “force fallback” toggle. You’ll catch contrast issues early without needing a physical Android 12 device for every developer.
# Step 5: Component Theming (Buttons, Inputs, AppBar, Cards)#
Component theming is where you win consistency. The rule is simple: if a component is used 10+ times, give it a component theme or a UI kit wrapper.
Button Theming#
// packages/ui_kit/lib/src/theme/component_themes.dart
import 'package:flutter/material.dart';
import '../tokens/app_radii.dart';
import '../tokens/app_spacing.dart';
class AppComponentThemes {
AppComponentThemes._();
static ElevatedButtonThemeData elevated(ColorScheme scheme, TextTheme text) {
return ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadii.md),
),
textStyle: text.labelLarge,
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
),
);
}
static InputDecorationTheme input(ColorScheme scheme) {
return InputDecorationTheme(
filled: true,
fillColor: scheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadii.md),
borderSide: BorderSide(color: scheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadii.md),
borderSide: BorderSide(color: scheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadii.md),
borderSide: BorderSide(color: scheme.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
);
}
}Compose into ThemeData:
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
textTheme: textTheme,
elevatedButtonTheme: AppComponentThemes.elevated(scheme, textTheme),
inputDecorationTheme: AppComponentThemes.input(scheme),
);When to Use Wrappers Instead of Themes#
Component themes don’t cover every design system need. Use UI kit widgets when:
- You need variant APIs like
AppButton(variant: primary, size: md) - You need layout consistency, not just colors and shapes
- You want to prevent “creative” styling in feature code
Keep wrappers thin. Wrap Material components, do not re-implement them.
# Step 6: Consistent Dark Mode Without Surprises#
Dark mode is not “invert colors.” It is a separate scheme with different surface behavior.
Practical Rules That Prevent Bugs#
- 1Always set both
themeanddarkThemeinMaterialApp. - 2Avoid hard-coded colors in features, especially
Colors.blackandColors.white. - 3Prefer
colorScheme.surface,surfaceContainer,onSurface,outlineVariant. - 4Validate common screens in both modes with golden tests.
⚠️ Warning: A common pitfall is using
Theme.of(context).primaryColor. In Material 3, preferTheme.of(context).colorScheme.primary.primaryColorcan produce unexpected results when you rely on generated schemes.
Contrast Checks That Catch Real Issues#
Teams often discover dark-mode contrast failures late. Catch them early:
- Ensure text on
surfaceusesonSurface - Ensure disabled states remain legible
- Ensure outlines and dividers don’t disappear in dark mode
Material 3 schemes are designed for contrast, but custom overrides can break it.
# Step 7: Spacing and Layout Consistency Across Features#
Flutter theming does not cover spacing the way web CSS variables do. You need a simple pattern:
- Define
AppSpacingtokens - Use them everywhere
- Avoid
EdgeInsets.all(10)in feature code
For frequently used layouts, define primitives like AppSection or AppPagePadding as wrappers in the UI kit. This removes repeated padding logic from features and keeps screens visually aligned.
// packages/ui_kit/lib/src/components/app_page.dart
import 'package:flutter/material.dart';
import '../tokens/app_spacing.dart';
class AppPage extends StatelessWidget {
final Widget child;
const AppPage({super.key, required this.child});
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: child,
),
);
}
}# Keeping It Consistent Across Feature Teams#
Consistency is as much process as code. Here’s what works in production.
One Entry Point for Theme, No Feature Overrides#
Features should not create their own ThemeData. If a feature needs something new:
- Add tokens or component theme centrally
- Provide a wrapper component if needed
- Deprecate old styling patterns
This plays well with feature-first setups and clean boundaries as described in Clean Architecture + feature-first.
Add Guardrails: Code Review and Lints#
At minimum, enforce:
- No raw
Color(...)in feature UI - No direct
TextStyle(...)unless justified - No random spacing values
You can’t realistically block everything with a linter, but you can make violations rare with conventions and review checklists.
# Designer-to-Developer Handoff: What to Ask For#
The fastest way to reduce design implementation churn is to agree on outputs.
Handoff Checklist#
| Artifact | Minimum | Why it matters |
|---|---|---|
| Color roles | Material 3 role mapping | Avoids “which gray is this” ambiguity |
| Type scale | Sizes, weights, line heights | Prevents one-off text styles |
| Spacing scale | 4-based or 8-based tokens | Keeps layouts aligned across screens |
| Component specs | States for hover, pressed, disabled, error | Prevents inconsistent interactions |
| Dark mode | Explicit dark scheme or rules | Avoids late-stage contrast fixes |
Map Design Tokens to Code Tokens#
Agree on naming upfront. If the designer uses Space/16, mirror it as AppSpacing.lg = 16. If they use Radius/12, mirror it as AppRadii.md = 12.
When teams align naming, code reviews become trivial and new developers onboard faster.
ℹ️ Note: If your design team uses Figma variables, consider exporting tokens to JSON and generating Dart constants. Even a manual “single source of truth” spreadsheet is better than copying values into code ad-hoc.
# Performance and Animations: The Theme Should Not Fight You#
Heavy theme rebuilds can show up as jank when paired with complex screens and animations. Keep theme creation centralized and immutable, and avoid rebuilding ThemeData on every frame.
If you’re chasing smoothness, combine clean theming with performance practices from Flutter performance optimization for 60fps.
If your design system includes motion, keep animation tokens consistent too. Don’t let each feature pick its own durations and curves. For deeper animation patterns, see Flutter animations guide: implicit, explicit, Rive, Lottie.
# Common Pitfalls (And How to Avoid Them)#
- 1
Hard-coded colors in feature widgets
UseColorSchemeand semantic theme extensions. If a needed color doesn’t exist, add a token. - 2
Ad-hoc typography
Map everything toTextTheme. Add a tiny extension for repeated variants. - 3
Spacing drift
Enforce a scale. Create small layout primitives likeAppPageto standardize padding. - 4
Component styling spread across codebase
Centralize in component themes or UI kit wrappers. Avoid localstyleFromoverrides everywhere. - 5
Dynamic color breaking brand identity
Keep dynamic as optional. Override critical brand roles if needed and test contrast.
# Key Takeaways#
- Define a token layer for colors, typography, spacing, and radii, and treat it as a stable API.
- Build
ThemeDatafrom tokens and centralize component theming to eliminate per-widget overrides. - Use dynamic color as an optional
ColorSchemesource with a robust brand fallback. - Enforce semantic typography via
TextThemeand small extensions, not ad-hocTextStyle. - Keep dark mode reliable by avoiding hard-coded colors and validating both modes with golden tests.
- Align designer-dev handoff on role-based naming and a shared token map to reduce rework.
# Conclusion#
A scalable Flutter design system theming setup is less about picking the “right colors” and more about creating a repeatable pipeline: tokens to theme composition to component themes, enforced across features. Material 3 and dynamic color can make your app feel more native, but only if you keep strong brand fallbacks and contrast-safe dark mode.
If you want help implementing a token-based UI kit, migrating to Material 3, or standardizing theming across a feature-first codebase, Samioda can audit your current UI layer and deliver a production-ready design system foundation. Reach out and we’ll map your tokens, theme architecture, and component strategy to your roadmap.
FAQ
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Mobile Development
All →Flutter Observability in Production: Crash Reporting, Logging, and Performance Monitoring
A practical 2026 guide to Flutter production observability: instrument crashes, non-fatal errors, API latency, app start, and frame timings. Includes Crashlytics vs Sentry comparison, integration steps, and a release checklist.
Flutter In-App Purchases & Subscriptions: Apple and Google Setup, Testing, and RevenueCat
A practical 2026 guide to Flutter in app purchases: product setup in App Store Connect and Play Console, subscriptions, entitlements, receipt validation, paywalls, sandbox testing, and debugging real production issues.
Flutter Local Database Comparison: Hive vs Isar vs sqflite vs Drift (2026 Guide)
A practical Flutter local database comparison for 2026: Hive vs Isar vs sqflite vs Drift, with guidance on performance, queries, migrations, encryption, and web support for common app types.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
Flutter In-App Purchases & Subscriptions: Apple and Google Setup, Testing, and RevenueCat
A practical 2026 guide to Flutter in app purchases: product setup in App Store Connect and Play Console, subscriptions, entitlements, receipt validation, paywalls, sandbox testing, and debugging real production issues.
Flutter Local Database Comparison: Hive vs Isar vs sqflite vs Drift (2026 Guide)
A practical Flutter local database comparison for 2026: Hive vs Isar vs sqflite vs Drift, with guidance on performance, queries, migrations, encryption, and web support for common app types.
Flutter Animations in Production: Implicit vs Explicit, Rive and Lottie, and Performance Tips
A practical Flutter animations guide for production apps: when to use implicit vs explicit animations, proven AnimationController patterns, Hero transitions, Rive and Lottie tradeoffs, plus performance and testing strategies to keep 60fps.