As engineering organizations scale, multiple mobile products often share common foundations: design language, typography scales, interaction models, and accessibility baselines.
Hardcoding colors like Color(0xFF2563EB) across UI components results in maintenance bottlenecks when re-branding or adding Dark Mode. In this article, we implement a token-based design system in Flutter utilizing ThemeExtension.
Design tokens decouple visual styling from individual UI widget implementations.
What are Design Tokens?
Design tokens are design atomics: named variables representing colors, spacing increments, radii, and typography styles:
- Global Tokens: Raw values (
blue-500: #3B82F6,radius-md: 12px). - Semantic Tokens: Purpose-driven mappings (
surface-background,action-primary,feedback-error). - Component Tokens: Widget-specific bindings (
button-primary-background,badge-border).
Creating a Semantic ThemeExtension
Flutter's built-in ThemeData covers basic Material properties, but lacks custom brand accents. ThemeExtension bridges this gap with seamless interpolation:
import 'package:flutter/material.dart';
@immutable
class AppBrandTokens extends ThemeExtension<AppBrandTokens> {
final Color accentGradientStart;
final Color accentGradientEnd;
final Color surfaceSubtle;
final double radiusCard;
const AppBrandTokens({
required this.accentGradientStart,
required this.accentGradientEnd,
required this.surfaceSubtle,
required this.radiusCard,
});
@override
AppBrandTokens copyWith({
Color? accentGradientStart,
Color? accentGradientEnd,
Color? surfaceSubtle,
double? radiusCard,
}) {
return AppBrandTokens(
accentGradientStart: accentGradientStart ?? this.accentGradientStart,
accentGradientEnd: accentGradientEnd ?? this.accentGradientEnd,
surfaceSubtle: surfaceSubtle ?? this.surfaceSubtle,
radiusCard: radiusCard ?? this.radiusCard,
);
}
@override
AppBrandTokens lerp(ThemeExtension<AppBrandTokens>? other, double t) {
if (other is! AppBrandTokens) return this;
return AppBrandTokens(
accentGradientStart: Color.lerp(accentGradientStart, other.accentGradientStart, t)!,
accentGradientEnd: Color.lerp(accentGradientEnd, other.accentGradientEnd, t)!,
surfaceSubtle: Color.lerp(surfaceSubtle, other.surfaceSubtle, t)!,
radiusCard: radiusCard + (other.radiusCard - radiusCard) * t,
);
}
}Registering and Consuming Tokens
// In your App Theme configuration:
final lightTheme = ThemeData.light().copyWith(
extensions: [
const AppBrandTokens(
accentGradientStart: Color(0xFF6366F1),
accentGradientEnd: Color(0xFF8B5CF6),
surfaceSubtle: Color(0xFFF9FAFB),
radiusCard: 16.0,
),
],
);
// In any widget:
class BrandedCard extends StatelessWidget {
const BrandedCard({super.key});
@override
Widget build(BuildContext context) {
final tokens = Theme.of(context).extension<AppBrandTokens>()!;
return Container(
decoration: BoxDecoration(
color: tokens.surfaceSubtle,
borderRadius: BorderRadius.circular(tokens.radiusCard),
border: Border.all(color: Colors.black12),
),
padding: const EdgeInsets.all(20),
child: Text('Token-Driven Design Component', style: Theme.of(context).textTheme.titleMedium),
);
}
}Architecture Takeaways
- Centralize color palettes into semantic tokens.
- Implement
lerpinsideThemeExtensionto guarantee smooth transitions during theme switches. - Test isolated widgets using Flutter 3.47+ Widget Previews without booting the full app.