DEV Community

Cover image for Managing Multi-Theme Design Systems in Flutter with Bloc & Freezed
مصعب يوسف
مصعب يوسف

Posted on Originally published at Medium on

Managing Multi-Theme Design Systems in Flutter with Bloc & Freezed

Managing Multi-Theme Design Systems in Flutter with Bloc & Freezed

Supporting dynamic design systems in Flutter apps isn’t just about toggling light and dark modes. In real-world, user-centric applications, developers often face the challenge of supporting high-contrast accessibility themes, switching typography on the fly, adjusting spacing dynamically, and even adapting icons or animations based on user preferences or device capabilities. Flutter gives us the building blocks, but combining Bloc, Freezed, and a well-structured design system unlocks true power and flexibility.

In this article, we’ll explore a clean, scalable architecture for managing multiple themes in Flutter, including plug-and-play design system principles, a Bloc-driven state management approach, and token-based extensibility inspired by Figma and Tailwind CSS.

As applications scale across platforms, screens, and accessibility requirements, your design system must:

  • Support multiple color schemes such as Light, Dark, and High-Contrast.
  • Enable dynamic spacing and typography, e.g. for improved readability or different branding.
  • Switch between icon sets or animations based on user preferences or hardware.
  • Allow runtime configuration of visual tokens or constraints.

A robust theme system ensures accessibility, brand consistency, and a flexible UI/UX without hardcoding styles in widgets.

lib/
  design_system/
    app_theme.dart
    design_tokens.dart
    theme_extensions.dart
    icon_theme.dart
  presentation/
    theme/
      cubit/
        theme_cubit.dart
        theme_state.dart
      widgets/
        theme_switcher.dart
        theme_preview.dart
Enter fullscreen mode Exit fullscreen mode

This structure allows you to separate concerns and extract the design system into a reusable module.

Design tokens represent spacing, typography, icon size, radius, shadows, etc., and are platform-agnostic representations of your design language.

class AppSpacing {
  final double sm;
  final double md;
  final double lg;
  final double xl;


const AppSpacing({
    required this.sm,
    required this.md,
    required this.lg,
    required this.xl,
  });
}
class AppTypography {
  final TextStyle headline;
  final TextStyle subtitle;
  final TextStyle body;
  const AppTypography({
    required this.headline,
    required this.subtitle,
    required this.body,
  });
}
class AppIcons {
  final double sizeSm;
  final double sizeMd;
  final double sizeLg;
  const AppIcons({required this.sizeSm, required this.sizeMd, required this.sizeLg});
}
Enter fullscreen mode Exit fullscreen mode

Theme extensions let you inject custom data into the ThemeData tree, allowing widgets to access spacing, icons, and typography without additional providers.

class DesignTokens extends ThemeExtension<DesignTokens> {
  final AppSpacing spacing;
  final AppTypography typography;
  final AppIcons icons;

const DesignTokens({
    required this.spacing,
    required this.typography,
    required this.icons,
  });
  @override
  DesignTokens copyWith({
    AppSpacing? spacing,
    AppTypography? typography,
    AppIcons? icons,
  }) {
    return DesignTokens(
      spacing: spacing ?? this.spacing,
      typography: typography ?? this.typography,
      icons: icons ?? this.icons,
    );
  }
  @override
  DesignTokens lerp(ThemeExtension<DesignTokens>? other, double t) {
    return this; // You can implement lerp for smooth transitions
  }
}
Enter fullscreen mode Exit fullscreen mode
final lightTheme = ThemeData.light().copyWith(
  extensions: [
    DesignTokens(
      spacing: AppSpacing(sm: 4, md: 8, lg: 16, xl: 32),
      typography: AppTypography(
        headline: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
        subtitle: TextStyle(fontSize: 18, fontWeight: FontWeight.w500),
        body: TextStyle(fontSize: 16),
      ),
      icons: AppIcons(sizeSm: 16, sizeMd: 24, sizeLg: 32),
    ),
  ],
);
Enter fullscreen mode Exit fullscreen mode

Using Freezed ensures immutability and convenient pattern matching:

@freezed
class ThemeState with _$ThemeState {
  const factory ThemeState({
    required ThemeMode mode,
    required ThemeData themeData,
  }) = _ThemeState;
}
Enter fullscreen mode Exit fullscreen mode

This state class supports both the mode and actual theme data instance.

This Cubit controls the current theme mode (light/dark/high-contrast).

class ThemeCubit extends Cubit<ThemeState> {
  ThemeCubit() : super(ThemeState(mode: ThemeMode.system,
     themeData: lightTheme
    )
      );


void toggleTheme() {
    if (state.mode == ThemeMode.light) {
      emit(state.copyWith(mode: ThemeMode.dark, themeData: darkTheme));
    } else {
      emit(state.copyWith(mode: ThemeMode.light, themeData: lightTheme));
    }
  }
  void setHighContrastTheme() {
    emit(state.copyWith(mode: ThemeMode.light, themeData: highContrastTheme));
  }
}
Enter fullscreen mode Exit fullscreen mode

You can also extend this with setCustomTheme(...) if you support user-customized themes.

One of the most powerful aspects of using ThemeExtension with design tokens is that your widgets can become completely decoupled from hardcoded values. Instead, they can read from a central source of truth that adapts to whatever theme is active.

Anywhere in the widget tree, you can retrieve the tokens like this:

final tokens = Theme.of(context).extension<DesignTokens>()!;
final spacing = tokens.spacing.md;
final titleStyle = tokens.typography.headline;
final iconSize = tokens.icons.sizeMd;
Enter fullscreen mode Exit fullscreen mode

This gives your UI a consistent look and feel across light, dark, or even high-contrast modes.

For example, you can create a reusable ThemedCard widget that uses token-based padding and typography:

class ThemedCard extends StatelessWidget {
  final String title;
  final Widget child;

const ThemedCard({required this.title, required this.child});
  @override
  Widget build(BuildContext context) {
    final tokens = Theme.of(context).extension<DesignTokens>()!;
    return Padding(
      padding: EdgeInsets.all(tokens.spacing.lg),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(title, style: tokens.typography.headline),
          SizedBox(height: tokens.spacing.md),
          child,
        ],
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This makes it incredibly easy to maintain spacing and font sizes consistently across the app while still allowing flexibility when switching themes.

You can even animate theme changes smoothly using AnimatedTheme or respond to user settings with BlocBuilder<ThemeCubit, ThemeState> to rebuild only relevant parts of the UI.

Anywhere in the widget tree, you can do:

final tokens = Theme.of(context).extension<DesignTokens>()!;
final spacing = tokens.spacing.md;
final titleStyle = tokens.typography.headline;
final iconSize = tokens.icons.sizeMd;
Enter fullscreen mode Exit fullscreen mode

This avoids tightly coupling UI logic with raw values and makes your widgets adaptive.

To reuse themes across multiple apps or modules, extract your design system as a standalone package.

design_system_package/
  lib/
    app_theme.dart
    extensions.dart
    typography.dart
    spacing.dart
    icons.dart
    colors.dart
Enter fullscreen mode Exit fullscreen mode

Then import it via path or Git dependency:

dependencies:
  design_system_package:
    path: ../design_system_package
Enter fullscreen mode Exit fullscreen mode

This promotes reusability, consistency, and scalability across projects.

You can generate or load design tokens from .json, .yaml, or even remote APIs to allow real-time theme updates or A/B testing based on user segments or device preferences. For example, you might define a JSON file that contains token values for spacing, typography, and icon sizes, and then load it during app startup or on-the-fly using a settings service. This allows your app to adopt design system values dynamically without needing a full rebuild or redeployment. It's especially useful for white-labeled apps, remote design configuration, or experiments with different UI styles. This also makes it easier to keep Flutter designs in sync with design platforms like Figma, where tokens can be exported and reused across platforms.

{
  "spacing": { "sm": 4, "md": 8, "lg": 16, "xl": 32 },
  "fontSizes": { "headline": 20, "subtitle": 18, "body": 16 },
  "icons": { "sizeSm": 16, "sizeMd": 24, "sizeLg": 32 }
}
Enter fullscreen mode Exit fullscreen mode

You can then parse these into DesignTokens at app start or via feature flags.

By architecting your design system using Bloc, Freezed, and ThemeExtension, you gain:

  • Clean separation of UI and style logic.
  • Theme switching via state management.
  • Full support for color, typography, spacing, and icon customizations.
  • Compatibility with accessibility requirements.
  • Scalable, token-driven architecture aligned with design tools like Figma or Tailwind.

This system is especially powerful for:

  • White-labeled apps with client-specific themes.
  • Deign tokens shared across Flutter, Web, and Native.
  • Apps targeting internationalization and accessibility.

If you’re building enterprise-grade Flutter apps, investing in a clean design system architecture is not optional — it’s essential for scaling, accessibility, and maintainability.

Top comments (0)