
Introduction
As your Flutter project grows, your UI code can quickly become repetitive and difficult to maintain. You’ll find yourself copying the same button styles, card layouts, and form fields across dozens of screens. That’s where reusable UI components come in. Creating modular widgets improves code quality, reduces duplication, and makes your app easier to scale. Companies like Alibaba, Google Pay, and BMW use Flutter’s widget composition to build consistent design systems that scale across hundreds of screens and multiple development teams. In this comprehensive guide, you’ll learn how to build reusable UI components in Flutter, covering component architecture, theming integration, advanced patterns like compound widgets, and real-world examples you can adapt for your projects.
Why Reusable Components Matter
Reusable widgets provide benefits that compound as your app grows:
Consistency: Every button, card, and input looks the same throughout the app. When designers update the style guide, you change one component instead of hunting through every screen.
Velocity: Once you have a component library, building new screens becomes assembly work. Drag in your AppButton, AppCard, and AppTextField, wire them up, and you’re done.
Testability: Isolated components are easy to test. Write widget tests once for your AppButton, and every screen using it benefits from that coverage.
Maintainability: When bug fixes or improvements happen in one place, they propagate everywhere the component is used.
Identifying Reusable Patterns
Look for these signals that a widget should become a reusable component:
// RED FLAG: Repeated styling across files
// File: login_screen.dart
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: () => login(),
child: Text('Login'),
)
// File: signup_screen.dart (same styling copy-pasted!)
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: () => signup(),
child: Text('Sign Up'),
)
When you see this pattern, it’s time to extract a reusable component.
Building Your First Component
Here’s how to transform that repeated button into a reusable widget:
// lib/widgets/buttons/app_button.dart
import 'package:flutter/material.dart';
enum AppButtonVariant { primary, secondary, danger, ghost }
enum AppButtonSize { small, medium, large }
class AppButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final AppButtonVariant variant;
final AppButtonSize size;
final IconData? leadingIcon;
final IconData? trailingIcon;
final bool isLoading;
final bool fullWidth;
const AppButton({
super.key,
required this.label,
this.onPressed,
this.variant = AppButtonVariant.primary,
this.size = AppButtonSize.medium,
this.leadingIcon,
this.trailingIcon,
this.isLoading = false,
this.fullWidth = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: fullWidth ? double.infinity : null,
height: _getHeight(),
child: ElevatedButton(
style: _getButtonStyle(theme),
onPressed: isLoading ? null : onPressed,
child: _buildContent(theme),
),
);
}
double _getHeight() {
switch (size) {
case AppButtonSize.small:
return 36;
case AppButtonSize.medium:
return 48;
case AppButtonSize.large:
return 56;
}
}
ButtonStyle _getButtonStyle(ThemeData theme) {
final colors = _getColors(theme);
return ElevatedButton.styleFrom(
backgroundColor: colors.$1,
foregroundColor: colors.$2,
disabledBackgroundColor: colors.$1.withOpacity(0.5),
padding: _getPadding(),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: variant == AppButtonVariant.ghost
? BorderSide(color: theme.colorScheme.outline)
: BorderSide.none,
),
elevation: variant == AppButtonVariant.ghost ? 0 : 2,
);
}
(Color, Color) _getColors(ThemeData theme) {
switch (variant) {
case AppButtonVariant.primary:
return (theme.colorScheme.primary, theme.colorScheme.onPrimary);
case AppButtonVariant.secondary:
return (theme.colorScheme.secondary, theme.colorScheme.onSecondary);
case AppButtonVariant.danger:
return (theme.colorScheme.error, theme.colorScheme.onError);
case AppButtonVariant.ghost:
return (Colors.transparent, theme.colorScheme.primary);
}
}
EdgeInsets _getPadding() {
switch (size) {
case AppButtonSize.small:
return const EdgeInsets.symmetric(horizontal: 16, vertical: 8);
case AppButtonSize.medium:
return const EdgeInsets.symmetric(horizontal: 24, vertical: 12);
case AppButtonSize.large:
return const EdgeInsets.symmetric(horizontal: 32, vertical: 16);
}
}
Widget _buildContent(ThemeData theme) {
if (isLoading) {
return SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(_getColors(theme).$2),
),
);
}
final textStyle = _getTextStyle(theme);
final children = [];
if (leadingIcon != null) {
children.add(Icon(leadingIcon, size: textStyle.fontSize! + 4));
children.add(const SizedBox(width: 8));
}
children.add(Text(label, style: textStyle));
if (trailingIcon != null) {
children.add(const SizedBox(width: 8));
children.add(Icon(trailingIcon, size: textStyle.fontSize! + 4));
}
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: children,
);
}
TextStyle _getTextStyle(ThemeData theme) {
switch (size) {
case AppButtonSize.small:
return theme.textTheme.labelMedium!;
case AppButtonSize.medium:
return theme.textTheme.labelLarge!;
case AppButtonSize.large:
return theme.textTheme.titleMedium!;
}
}
}
Now usage becomes simple and consistent:
// Clean, expressive usage
AppButton(
label: 'Login',
onPressed: () => login(),
fullWidth: true,
)
AppButton(
label: 'Delete Account',
variant: AppButtonVariant.danger,
leadingIcon: Icons.delete,
onPressed: () => showDeleteConfirmation(),
)
AppButton(
label: 'Processing...',
isLoading: true,
onPressed: null,
)
Building a Card Component
Cards are used everywhere. Create a flexible card component that handles common patterns:
// lib/widgets/cards/app_card.dart
class AppCard extends StatelessWidget {
final Widget child;
final EdgeInsets? padding;
final VoidCallback? onTap;
final Color? backgroundColor;
final double elevation;
final BorderRadius? borderRadius;
final Widget? header;
final Widget? footer;
const AppCard({
super.key,
required this.child,
this.padding,
this.onTap,
this.backgroundColor,
this.elevation = 1,
this.borderRadius,
this.header,
this.footer,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBorderRadius = borderRadius ?? BorderRadius.circular(12);
final effectivePadding = padding ?? const EdgeInsets.all(16);
Widget card = Material(
color: backgroundColor ?? theme.cardColor,
elevation: elevation,
borderRadius: effectiveBorderRadius,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (header != null) ...[
Padding(
padding: EdgeInsets.fromLTRB(
effectivePadding.left,
effectivePadding.top,
effectivePadding.right,
0,
),
child: header,
),
Divider(height: effectivePadding.top),
],
Padding(
padding: header != null && footer != null
? EdgeInsets.symmetric(horizontal: effectivePadding.left)
: effectivePadding,
child: child,
),
if (footer != null) ...[
Divider(height: effectivePadding.bottom),
Padding(
padding: EdgeInsets.fromLTRB(
effectivePadding.left,
0,
effectivePadding.right,
effectivePadding.bottom,
),
child: footer,
),
],
],
),
);
if (onTap != null) {
card = InkWell(
onTap: onTap,
borderRadius: effectiveBorderRadius,
child: card,
);
}
return card;
}
}
// Usage examples
AppCard(
onTap: () => navigateToDetails(product),
header: Text('Product Details', style: theme.textTheme.titleMedium),
child: Column(
children: [
Image.network(product.imageUrl),
Text(product.name),
Text('\$${product.price}'),
],
),
footer: AppButton(
label: 'Add to Cart',
onPressed: () => addToCart(product),
fullWidth: true,
),
)
Form Input Components
Forms benefit enormously from reusable components with built-in validation:
// lib/widgets/forms/app_text_field.dart
class AppTextField extends StatelessWidget {
final String label;
final String? hint;
final TextEditingController? controller;
final String? Function(String?)? validator;
final TextInputType keyboardType;
final bool obscureText;
final int maxLines;
final Widget? prefix;
final Widget? suffix;
final void Function(String)? onChanged;
final bool enabled;
const AppTextField({
super.key,
required this.label,
this.hint,
this.controller,
this.validator,
this.keyboardType = TextInputType.text,
this.obscureText = false,
this.maxLines = 1,
this.prefix,
this.suffix,
this.onChanged,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
TextFormField(
controller: controller,
validator: validator,
keyboardType: keyboardType,
obscureText: obscureText,
maxLines: maxLines,
enabled: enabled,
onChanged: onChanged,
decoration: InputDecoration(
hintText: hint,
prefixIcon: prefix,
suffixIcon: suffix,
filled: true,
fillColor: enabled
? theme.colorScheme.surfaceVariant.withOpacity(0.3)
: theme.disabledColor.withOpacity(0.1),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: theme.colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: theme.colorScheme.primary, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: theme.colorScheme.error),
),
),
),
],
);
}
}
// Specialized email field
class AppEmailField extends StatelessWidget {
final TextEditingController? controller;
final void Function(String)? onChanged;
const AppEmailField({super.key, this.controller, this.onChanged});
@override
Widget build(BuildContext context) {
return AppTextField(
label: 'Email',
hint: 'you@example.com',
controller: controller,
keyboardType: TextInputType.emailAddress,
prefix: const Icon(Icons.email_outlined),
onChanged: onChanged,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value)) {
return 'Enter a valid email address';
}
return null;
},
);
}
}
Compound Widget Pattern
For complex components, use the compound widget pattern where a parent widget configures children:
// lib/widgets/lists/app_list_tile.dart
class AppListTile extends StatelessWidget {
final Widget? leading;
final String title;
final String? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
final EdgeInsets? padding;
const AppListTile({
super.key,
this.leading,
required this.title,
this.subtitle,
this.trailing,
this.onTap,
this.padding,
});
// Factory constructor for common avatar pattern
factory AppListTile.avatar({
required String imageUrl,
required String title,
String? subtitle,
Widget? trailing,
VoidCallback? onTap,
}) {
return AppListTile(
leading: CircleAvatar(
backgroundImage: NetworkImage(imageUrl),
),
title: title,
subtitle: subtitle,
trailing: trailing,
onTap: onTap,
);
}
// Factory constructor for icon pattern
factory AppListTile.icon({
required IconData icon,
required String title,
String? subtitle,
Widget? trailing,
VoidCallback? onTap,
}) {
return AppListTile(
leading: Icon(icon),
title: title,
subtitle: subtitle,
trailing: trailing ?? const Icon(Icons.chevron_right),
onTap: onTap,
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectivePadding = padding ??
const EdgeInsets.symmetric(horizontal: 16, vertical: 12);
Widget tile = Padding(
padding: effectivePadding,
child: Row(
children: [
if (leading != null) ...[
leading!,
const SizedBox(width: 16),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
if (subtitle != null)
Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (trailing != null) trailing!,
],
),
);
if (onTap != null) {
tile = InkWell(onTap: onTap, child: tile);
}
return tile;
}
}
// Usage
AppListTile.avatar(
imageUrl: user.avatarUrl,
title: user.name,
subtitle: user.email,
onTap: () => navigateToProfile(user),
)
AppListTile.icon(
icon: Icons.settings,
title: 'Settings',
subtitle: 'App preferences and configuration',
onTap: () => navigateToSettings(),
)
Organizing Your Component Library
Structure your widgets folder for scalability:
lib/
widgets/
buttons/
app_button.dart
icon_button.dart
index.dart # Exports all buttons
cards/
app_card.dart
product_card.dart
index.dart
forms/
app_text_field.dart
app_dropdown.dart
index.dart
lists/
app_list_tile.dart
index.dart
index.dart # Main barrel export
// lib/widgets/index.dart
export 'buttons/index.dart';
export 'cards/index.dart';
export 'forms/index.dart';
export 'lists/index.dart';
// Usage anywhere in app
import 'package:myapp/widgets/index.dart';
Common Mistakes to Avoid
Too many parameters: If your component has 20+ parameters, it’s doing too much. Break it into smaller, focused components.
Hardcoded colors and sizes: Always use Theme.of(context) for colors and consider the device’s text scaling. Hardcoded values break in dark mode or accessibility settings.
Business logic in UI: Components should receive data and callbacks, not make API calls or manage state. Keep them “dumb.”
Forgetting const constructors: Missing const prevents Flutter’s optimization. Always add const to constructors when possible.
Not testing components: Isolated components are perfect for widget tests. Write tests for each variant and edge case.
Conclusion
Building reusable UI components in Flutter transforms how you develop apps. Instead of copying styles across files, you build once and use everywhere. Start with the components you use most—buttons, cards, and text fields—then expand your library as patterns emerge. Use enums for variants, integrate with your theme for consistency, and keep business logic out of your components. The compound widget pattern handles complex cases elegantly, and proper folder organization keeps everything maintainable as your library grows. Your future self (and your team) will thank you when updating a design takes minutes instead of hours. For more on structuring your Flutter projects, check out our guide on Scalable Flutter Project Structure. For the complete widget catalog and advanced patterns, explore the official Flutter widget documentation.
1 Comment