Custom Theme Extensions
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
- ThemeExtension API
- Material 3 theming (Flutter docs)
- ColorScheme.fromSeed
- flex_color_scheme package
- Material Design 3 color roles
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 →