Design System Implementation

Medium PriorityAsked in ~45% of senior interviews

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

PracticeWhy
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 screensForces token usage
Token JSON exchange with designers (Style Dictionary)Designers update tokens, you regenerate Dart
Document each component's props and intended usageReduces "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

  1. 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.

  2. When would you wrap Material widgets vs use them directly? Wrap when you want to constrain the API (only certain variants), add domain semantics (AppButton vs ElevatedButton), or enforce design system rules (AppCard with mandatory padding). Use Material directly when the component is rarely used and themed correctly via ThemeData already.

  3. 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 from Theme.of(context) and Theme.of(context).extension<AppColors>() — same widget code, different visuals per brand.

  4. 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.