Custom Theme Extensions

FlutterDartThemingMobile
Share on LinkedIn

Marketing handed us seventeen brand colors. Stuffing them into ColorScheme.secondaryContainer and pretending they meant "chart axis" broke the day we adopted Material 3 dynamic color. ThemeExtension gave us BrandColors and AppSpacing on Theme.of(context)—typed, dark-mode aware, and animatable.

Defining an extension

@immutable
class BrandColors extends ThemeExtension<BrandColors> {
  const BrandColors({
    required this.success,
    required this.warning,
    required this.chartLine,
  });

  final Color success;
  final Color warning;
  final Color chartLine;

  @override
  BrandColors copyWith({
    Color? success,
    Color? warning,
    Color? chartLine,
  }) {
    return BrandColors(
      success: success ?? this.success,
      warning: warning ?? this.warning,
      chartLine: chartLine ?? this.chartLine,
    );
  }

  @override
  BrandColors lerp(ThemeExtension<BrandColors>? other, double t) {
    if (other is! BrandColors) return this;
    return BrandColors(
      success: Color.lerp(success, other.success, t)!,
      warning: Color.lerp(warning, other.warning, t)!,
      chartLine: Color.lerp(chartLine, other.chartLine, t)!,
    );
  }
}

Implement lerp for smooth theme animation—required for ThemeData.lerp.

Registering on ThemeData

ThemeData buildLightTheme() {
  final colorScheme = ColorScheme.fromSeed(seedColor: const Color(0xFF1E3A5F));
  return ThemeData(
    colorScheme: colorScheme,
    useMaterial3: true,
    extensions: const [
      BrandColors(
        success: Color(0xFF2E7D32),
        warning: Color(0xFFF9A825),
        chartLine: Color(0xFF1565C0),
      ),
      AppSpacing.standard(),
    ],
  );
}

Dark theme gets its own extension values with adjusted contrast.

Accessing in widgets

extension BuildContextTheme on BuildContext {
  BrandColors get brand =>
      Theme.of(this).extension<BrandColors>()!;
}

// usage
Container(color: context.brand.success)

Null assert if extensions always registered— or provide fallbacks in debug.

Spacing extension example

class AppSpacing extends ThemeExtension<AppSpacing> {
  const AppSpacing({this.s = 8, this.m = 16, this.l = 24});
  final double s, m, l;

  static const standard = AppSpacing();

  @override
  AppSpacing copyWith({double? s, double? m, double? l}) =>
      AppSpacing(s: s ?? this.s, m: m ?? this.m, l: l ?? this.l);

  @override
  AppSpacing lerp(ThemeExtension<AppSpacing>? other, double t) {
    if (other is! AppSpacing) return this;
    return AppSpacing(
      s: lerpDouble(s, other.s, t)!,
      m: lerpDouble(m, other.m, t)!,
      l: lerpDouble(l, other.l, t)!,
    );
  }
}

Design tokens live in one place—not magic numbers in widgets.

Dynamic color integration

When using Material 3 dynamic color from wallpaper, derive extensions from generated ColorScheme:

BrandColors fromScheme(ColorScheme scheme) => BrandColors(
  success: scheme.tertiary,
  warning: scheme.errorContainer,
  chartLine: scheme.primary,
);

Document which roles map to which UI so designers audit contrast.

Testing themes

testWidgets('success banner uses brand success', (tester) async {
  await tester.pumpWidget(
    MaterialApp(
      theme: buildLightTheme(),
      home: const SuccessBanner(),
    ),
  );
  // assert decoration color matches extension
});

Dark mode lerp quality

Test animated theme toggle—lerp on Color works; custom types need careful interpolation:

BrandColors lerp(ThemeExtension<BrandColors>? other, double t) {
  if (other is! BrandColors) return this;
  return BrandColors(
    success: Color.lerp(success, other.success, t)!,
    ...
  );
}

Extension discovery in design tools

Export token JSON from same source as ThemeExtension values—designers edit Figma variables; engineers sync JSON in CI diff.

Multiple extension types

extensions: [BrandColors.light, AppSpacing.standard, Elevations.light],

Access via Theme.of(context).extension<BrandColors>()—keep extension count manageable (<5 types).

Composition with Material themes

ThemeExtension does not replace ColorScheme—use both. ColorScheme for Material components; BrandColors for domain-specific visualization (charts, badges, status pills) that Material roles do not model.

Code generation option

Large token sets may generate extension classes from JSON—same source as CSS tokens in web monorepo. Hand-written extensions fine until token count exceeds ~30 fields.

Runtime theme switching

ThemeData light = buildTheme(Brightness.light, brand: BrandColors.light);
ThemeData dark = buildTheme(Brightness.dark, brand: BrandColors.dark);

ThemeMode.system listens to OS—test toggle in app settings duplicates ThemeMode override for user preference independent of OS.

Accessibility audit

Verify semantic colors (success/warning/error) meet contrast on both surface variants—do not rely on hue alone; pair with icons in UI for colorblind users.

Extension equality

ThemeExtension copyWith must be correct for ThemeAnimation—incorrect lerp causes flicker during dark mode toggle; test ThemeData.lerp between light and dark in widget test.

Rollout guidance

Brand extension tokens reviewed brand team quarterly screenshot Figma next to Widgetbook same components side by side meeting calendar invite recurring.

Team practices

Shipping Flutter Theme Extensions Custom in production taught our team that documentation beats hero demos. We wrote runbooks covering failure modes, on-call steps, and rollback triggers before the second release, not after the first outage.

When reviewing PRs touching Flutter Theme Extensions Custom, we ask for measurable outcomes: latency, crash rate, or support ticket volume—not subjective feels faster. If metrics are unavailable, the PR includes a DevTools trace or load test snippet proving the change.

Onboarding engineers paste a checklist into their first Flutter Theme Extensions Custom PR description: environment setup verified, tests added or updated, accessibility spot-checked, and release notes drafted for user-visible changes.

We keep a living FAQ in the repo wiki for Flutter Theme Extensions Custom questions repeated in Slack more than twice. FAQ entries link to source files and Slack threads, reducing tribal knowledge and repeated explanations in code review.

Cross-functional review includes design for UX-facing work, security for auth or storage, and platform for native bridges. Flutter Theme Extensions Custom spans layers; skipping reviewers recreated bugs we fixed months ago.

Staging soaks 24 hours for risky changes while dashboards watch error rates.canary cohorts internal staff first, then five percent production, then full rollout if crash-free sessions hold within baseline tolerance.

Post-release we schedule a short retro even on smooth launches—what signal caught issues early, what was noise. Flutter Theme Extensions Custom improvements compound when feedback loops stay short and blameless.

Resources

Frequently asked questions

ThemeExtension vs extending ThemeData?

Subclassing ThemeData breaks with Flutter updates and composes poorly. ThemeExtension is the supported hook for custom tokens—merged into ThemeData.extensions and accessed with extension<T>().

Do theme extensions work with dark mode?

Yes. Provide light and dark instances in ThemeData(extensions: [...]) for each ThemeData you build. lerp in copyWith enables animated theme transitions.

Can I put TextStyle in ThemeExtension?

Absolutely—store typography roles, spacing scales, elevation shadows, or semantic colors like success/warning that ColorScheme lacks.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →