Mobile Development
FlutterMaterial 3Design SystemThemingMobile DevelopmentDark ModeTypography

Flutter Design System & Theming in 2026: Material 3, Dynamic Color, Typography, and Dark Mode

AO
Adrijan Omićević
·14 min read

# 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 ColorScheme and 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#

RequirementVersionNotes
Flutter3.22+Material 3 support is mature; update to the latest stable if possible
Dart3.4+For modern language features and better analyzer support
Material 3EnabledSet useMaterial3: true in ThemeData
Android12+Needed for dynamic color; still provide fallback schemes
Basic design system artifactsColor palette, typographic scale, spacing scale from design

# Theme Architecture: Tokens First, ThemeData Second#

A good Flutter theme architecture separates concerns:

  1. 1
    Tokens define raw values and semantic meanings.
  2. 2
    Theme composition builds ThemeData from tokens.
  3. 3
    Component theming standardizes widgets without local overrides.
  4. 4
    Consumption in features is read-only: features use theme, never define it.

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).

LayerExample pathResponsibility
Tokenspackages/ui_kit/lib/src/tokens/Colors, typography, spacing, radii, elevation
Theme builderspackages/ui_kit/lib/src/theme/Build ThemeData for light and dark
Componentspackages/ui_kit/lib/src/components/Buttons, inputs, app bars, cards
Public APIpackages/ui_kit/lib/ui_kit.dartExport stable interfaces only

This keeps features clean and prevents circular dependencies when your app grows.

💡 Tip: Make tokens immutable and avoid pulling BuildContext into 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.

Dart
// 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);
}
Dart
// 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.

Dart
// 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.

Dart
// 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.

Dart
// 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:

Dart
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.

Dart
// 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:

Dart
Text(
  'Updated 2 hours ago',
  style: Theme.of(context).textTheme.bodyMuted,
)

ℹ️ Note: Flutter’s TextTheme doesn’t carry color by default. Many teams rely on DefaultTextStyle and ColorScheme.onSurface. If you use copyWith(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#

Dart
// 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.

Dart
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.

Dart
// 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:

Dart
// 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#

Dart
// 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:

Dart
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#

  1. 1
    Always set both theme and darkTheme in MaterialApp.
  2. 2
    Avoid hard-coded colors in features, especially Colors.black and Colors.white.
  3. 3
    Prefer colorScheme.surface, surfaceContainer, onSurface, outlineVariant.
  4. 4
    Validate common screens in both modes with golden tests.

⚠️ Warning: A common pitfall is using Theme.of(context).primaryColor. In Material 3, prefer Theme.of(context).colorScheme.primary. primaryColor can 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 surface uses onSurface
  • 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 AppSpacing tokens
  • 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.

Dart
// 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#

ArtifactMinimumWhy it matters
Color rolesMaterial 3 role mappingAvoids “which gray is this” ambiguity
Type scaleSizes, weights, line heightsPrevents one-off text styles
Spacing scale4-based or 8-based tokensKeeps layouts aligned across screens
Component specsStates for hover, pressed, disabled, errorPrevents inconsistent interactions
Dark modeExplicit dark scheme or rulesAvoids 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. 1

    Hard-coded colors in feature widgets
    Use ColorScheme and semantic theme extensions. If a needed color doesn’t exist, add a token.

  2. 2

    Ad-hoc typography
    Map everything to TextTheme. Add a tiny extension for repeated variants.

  3. 3

    Spacing drift
    Enforce a scale. Create small layout primitives like AppPage to standardize padding.

  4. 4

    Component styling spread across codebase
    Centralize in component themes or UI kit wrappers. Avoid local styleFrom overrides everywhere.

  5. 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 ThemeData from tokens and centralize component theming to eliminate per-widget overrides.
  • Use dynamic color as an optional ColorScheme source with a robust brand fallback.
  • Enforce semantic typography via TextTheme and small extensions, not ad-hoc TextStyle.
  • 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

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.