Building a design system in Flutter
By Charlin Joe · 18 July 2025
A design system is a shared language between designers and engineers. In Flutter it means: a consistent set of colours, typography, spacing, and components that every screen in the app uses. Without one, every developer makes slightly different choices — button radius here, wrong shade of blue there — and the UI drifts into inconsistency.
Start with tokens, not components
Design tokens are the primitive values everything else is built from. Define them in one place:
// lib/design/tokens.dart
class AppColors {
AppColors._();
static const primary = Color(0xFF0A1628);
static const primaryLight = Color(0xFF1A3A5C);
static const accent = Color(0xFF2979FF);
static const error = Color(0xFFD32F2F);
static const warning = Color(0xFFF57C00);
static const success = Color(0xFF2E7D32);
// Neutrals
static const gray100 = Color(0xFFF5F5F5);
static const gray200 = Color(0xFFEEEEEE);
static const gray400 = Color(0xFFBDBDBD);
static const gray600 = Color(0xFF757575);
static const gray900 = Color(0xFF212121);
// Semantic
static const surface = Color(0xFFFFFFFF);
static const onSurface = gray900;
static const background = gray100;
}
class AppSpacing {
AppSpacing._();
static const double xs = 4;
static const double sm = 8;
static const double md = 16;
static const double lg = 24;
static const double xl = 32;
static const double xxl = 48;
}
class AppRadius {
AppRadius._();
static const double sm = 4;
static const double md = 8;
static const double lg = 16;
static const double full = 999; // Pill shape
}
Build the theme around your tokens
// lib/design/theme.dart
ThemeData buildAppTheme() {
const textTheme = TextTheme(
displayLarge: TextStyle(fontSize: 57, fontWeight: FontWeight.w400, letterSpacing: -0.25),
headlineMedium: TextStyle(fontSize: 28, fontWeight: FontWeight.w600),
titleLarge: TextStyle(fontSize: 22, fontWeight: FontWeight.w600),
titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, letterSpacing: 0.15),
bodyLarge: TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.5),
bodyMedium: TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1.5),
labelLarge: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, letterSpacing: 0.1),
);
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.light(
primary: AppColors.primary,
secondary: AppColors.accent,
error: AppColors.error,
surface: AppColors.surface,
),
textTheme: textTheme.apply(
bodyColor: AppColors.onSurface,
displayColor: AppColors.onSurface,
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
),
textStyle: textTheme.labelLarge,
),
),
);
}
Build composable components
Components wrap Material widgets with your design decisions baked in:
// lib/design/components/app_button.dart
enum AppButtonVariant { primary, secondary, ghost, danger }
class AppButton extends StatelessWidget {
const AppButton({
super.key,
required this.label,
required this.onPressed,
this.variant = AppButtonVariant.primary,
this.loading = false,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final AppButtonVariant variant;
final bool loading;
final IconData? icon;
@override
Widget build(BuildContext context) {
final (bg, fg) = switch (variant) {
AppButtonVariant.primary => (AppColors.primary, Colors.white),
AppButtonVariant.secondary => (AppColors.gray100, AppColors.primary),
AppButtonVariant.ghost => (Colors.transparent, AppColors.primary),
AppButtonVariant.danger => (AppColors.error, Colors.white),
};
return ElevatedButton(
onPressed: loading ? null : onPressed,
style: ElevatedButton.styleFrom(backgroundColor: bg, foregroundColor: fg),
child: loading
? SizedBox.square(
dimension: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: fg,
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[Icon(icon, size: 18), const SizedBox(width: 8)],
Text(label),
],
),
);
}
}
Spacing and layout helpers
Create helpers that enforce the spacing scale:
// Vertical space with named constructors
class VSpace extends SizedBox {
const VSpace.xs() : super(height: AppSpacing.xs);
const VSpace.sm() : super(height: AppSpacing.sm);
const VSpace.md() : super(height: AppSpacing.md);
const VSpace.lg() : super(height: AppSpacing.lg);
const VSpace.xl() : super(height: AppSpacing.xl);
}
class HSpace extends SizedBox {
const HSpace.xs() : super(width: AppSpacing.xs);
const HSpace.sm() : super(width: AppSpacing.sm);
const HSpace.md() : super(width: AppSpacing.md);
const HSpace.lg() : super(width: AppSpacing.lg);
}
// Usage
Column(
children: [
const AppText.heading('Hello'),
const VSpace.md(),
const AppText.body('Some body text.'),
const VSpace.lg(),
AppButton(label: 'Continue', onPressed: () {}),
],
)
Typography component
class AppText extends StatelessWidget {
const AppText.heading(this.text, {super.key, this.color})
: style = _Style.heading;
const AppText.subheading(this.text, {super.key, this.color})
: style = _Style.subheading;
const AppText.body(this.text, {super.key, this.color})
: style = _Style.body;
const AppText.caption(this.text, {super.key, this.color})
: style = _Style.caption;
final String text;
final _Style style;
final Color? color;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).textTheme;
final baseStyle = switch (style) {
_Style.heading => theme.headlineMedium,
_Style.subheading => theme.titleMedium,
_Style.body => theme.bodyLarge,
_Style.caption => theme.bodyMedium?.copyWith(color: AppColors.gray600),
};
return Text(text, style: baseStyle?.copyWith(color: color));
}
}
enum _Style { heading, subheading, body, caption }
Dark mode support
Dark mode is easiest when you've defined semantic tokens:
ThemeData buildDarkTheme() => buildAppTheme().copyWith(
brightness: Brightness.dark,
colorScheme: const ColorScheme.dark(
primary: AppColors.primaryLight,
secondary: AppColors.accent,
surface: Color(0xFF121212),
),
);
// In MaterialApp:
MaterialApp(
theme: buildAppTheme(),
darkTheme: buildDarkTheme(),
themeMode: ThemeMode.system,
)
Common pitfalls
Hardcoding colours in widgets. If Color(0xFF0A1628) appears anywhere other than tokens.dart, the design system is already broken. Lint rule: forbid raw Color literals outside the tokens file.
Components that are too rigid. A button that can't accept an icon forces developers to bypass the system. Make components flexible with optional parameters — but keep defaults that enforce the design.
Skipping the text theme. Direct TextStyle(...) calls in widgets are the most common drift. Route all text through Theme.of(context).textTheme or AppText.
No Widgetbook or Storybook. Without a component gallery, developers don't know what components exist and recreate them. Add widgetbook to render all components in isolation.
Sign in to like, dislike, or report.