Design System Implementation
4 min read
Architecture
Tokens (primitives) → spacing, radius, elevation, color seed
↓
Theme (semantic) → ColorScheme, TextTheme, *ThemeData
↓
Components (opinionated) → AppButton, AppCard, AppDialog
↓
Screens (composition) → CheckoutScreen, HomeScreen
Each layer depends only on the one above. Screens never reach into tokens directly — they use components; components use theme; theme uses tokens.
Code in action
// 1️⃣ Tokens
abstract final class AppSpacing {
static const xs = 4.0;
static const sm = 8.0;
static const md = 16.0;
static const lg = 24.0;
static const xl = 32.0;
}
abstract final class AppRadius {
static const sm = 4.0;
static const md = 8.0;
static const lg = 16.0;
static const full = 999.0;
}
// 2️⃣ Theme — Material 3 seed-based
class AppTheme {
static ThemeData light() => _build(Brightness.light);
static ThemeData dark() => _build(Brightness.dark);
static ThemeData _build(Brightness b) {
final scheme = ColorScheme.fromSeed(seedColor: const Color(0xFF1A73E8), brightness: b);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
textTheme: const TextTheme(
displayLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
titleLarge: TextStyle(fontSize: 22, fontWeight: FontWeight.w600),
bodyLarge: TextStyle(fontSize: 16, height: 1.5),
bodyMedium: TextStyle(fontSize: 14, height: 1.5),
labelLarge: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg, vertical: AppSpacing.sm),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md)),
),
),
);
}
}
// 3️⃣ Components — opinionated, variant-only API
enum AppButtonVariant { primary, secondary, text }
class AppButton extends StatelessWidget {
const AppButton({
super.key,
required this.label,
required this.onPressed,
this.variant = AppButtonVariant.primary,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final AppButtonVariant variant;
final IconData? icon;
@override
Widget build(BuildContext context) {
final child = icon == null
? Text(label)
: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 18),
const SizedBox(width: AppSpacing.xs),
Text(label),
]);
return switch (variant) {
AppButtonVariant.primary => FilledButton(onPressed: onPressed, child: child),
AppButtonVariant.secondary => OutlinedButton(onPressed: onPressed, child: child),
AppButtonVariant.text => TextButton(onPressed: onPressed, child: child),
};
}
}
Operational patterns
| Practice | Why |
|---|---|
Components live in their own package (design_system/) | Independent versioning, reusable across apps |
| Golden test every component variant + state (light/dark, enabled/disabled) | Catch visual regressions in PR |
Storybook screen (widgetbook package) | Designers + QA see every component live |
Lint against direct Color, EdgeInsets literals in screens | Forces token usage |
| Token JSON exchange with designers (Style Dictionary) | Designers update tokens, you regenerate Dart |
| Document each component's props and intended usage | Reduces "what's the right button to use here?" |
Extending Theme — custom theme extensions
For tokens that don't fit standard Material slots (e.g., custom semantic colors, branded spacing):
class AppColors extends ThemeExtension<AppColors> {
const AppColors({required this.success, required this.warning});
final Color success;
final Color warning;
@override
AppColors copyWith({Color? success, Color? warning}) =>
AppColors(success: success ?? this.success, warning: warning ?? this.warning);
@override
AppColors lerp(AppColors? other, double t) =>
AppColors(
success: Color.lerp(success, other?.success, t)!,
warning: Color.lerp(warning, other?.warning, t)!,
);
}
// Register
ThemeData(extensions: [const AppColors(success: Color(0xFF2BB673), warning: Color(0xFFFFB020))]);
// Use
Theme.of(context).extension<AppColors>()!.success;
Common mistakes to avoid
❌ Hard-coded colors / spacing in screens
Color(0xFFCCCCCC), EdgeInsets.all(13) — scattered, impossible to update.
✅ Tokens + theme; lint against literals (custom analyzer rule or convention).
❌ Components with 20 props that allow infinite customization
Becomes "anything goes" — defeats the design system.
✅ Limited variants (primary/secondary/text), opinionated defaults.
❌ Two design systems (Material + your own) competing
✅ Wrap or replace Material widgets consistently; don't mix raw and themed widgets.
❌ No golden tests for shared components
First PR that "fixes a button padding" silently changes every screen.
✅ Golden test per component variant; PR shows visual diff.
❌ Tokens only in Dart
Designers iterate in Figma; engineers update Dart by hand → drift.
✅ Style Dictionary or design-tokens.json shared source.
❌ Forgetting dark mode from day one
Retrofit is painful — find hard-coded colors, replace, retest every screen.
✅ Define both light + dark in the theme, design components for both.
❌ Treating ThemeData as "just colors"
Buttons, text fields, inputs, dialogs ALL have *ThemeData. Configure them
at the theme level, not at every call site.
Interview follow-ups
-
How do you keep Figma tokens and Dart code in sync? Use a token exchange format like Design Tokens (W3C spec) or Style Dictionary. Designers export tokens.json from Figma; a build step generates
app_tokens.dart. Now changes flow one direction — no manual transcription. Most large design systems do this. -
When would you wrap Material widgets vs use them directly? Wrap when you want to constrain the API (only certain variants), add domain semantics (
AppButtonvsElevatedButton), or enforce design system rules (AppCardwith mandatory padding). Use Material directly when the component is rarely used and themed correctly viaThemeDataalready. -
How do you handle multi-brand or white-label apps with shared components? Components live in a shared package, but the theme is per brand. Each brand provides its own
ColorScheme.fromSeed+TextTheme+ ThemeExtensions. Components read fromTheme.of(context)andTheme.of(context).extension<AppColors>()— same widget code, different visuals per brand. -
What's the role of golden tests in maintaining a design system? They lock down the visual contract. When a PR changes a token or component, golden diffs surface every screen that's visually affected. Without them, "I changed the button radius from 8 to 12" silently changes 50 screens. With them, the diff is the review.
How helpful was this content?
Please sign in to rate this article.