Building Custom Widgets
Extract, Compose, and Reuse
Open interactive version (quiz + challenge)Real-world analogy
Building custom widgets is like creating your own LEGO pieces. Instead of building the same castle tower from 50 individual bricks every time, you mold those 50 bricks into one custom 'tower piece' that you can stamp out instantly. You can even make it adjustable — different colors, heights, or flags — by adding parameters. Now your castle is built from a few custom pieces instead of thousands of tiny bricks.
What is it?
Building custom widgets means extracting reusable UI components into their own widget classes with parameters for customization, callbacks for events, and widget slots for flexible content. Flutter's composition model encourages building complex UIs from small, focused, reusable pieces rather than monolithic build methods.
Real-world relevance
In team_mvp_kit, custom widgets like PrimaryButton, AppTextField, StatusBadge, and UserAvatar are used across dozens of screens. The project has a widgets/ folder with reusable components that accept parameters and callbacks. This approach ensures visual consistency, reduces code duplication, and makes UI changes propagate everywhere from a single edit.
Key points
- Extracting Widgets — When your build method gets long, extract sections into separate widget classes. Right-click in your IDE and select 'Extract Widget' or manually create a new StatelessWidget. Each extracted widget has its own build method, making code easier to read, test, and reuse across screens.
- Parameters for Customization — Custom widgets accept parameters through their constructor, just like built-in Flutter widgets. Use required named parameters for essential data and optional parameters with defaults for customization. This makes your widget flexible enough to use in different contexts with different data.
- Composition Over Inheritance — In Flutter, you build complex widgets by composing simpler ones — not by extending them. Instead of creating a class that extends ElevatedButton, create a widget that CONTAINS an ElevatedButton with your customizations. This composition pattern is how Flutter is designed and gives you full control.
- Callback Parameters — Pass functions as parameters to let parent widgets respond to events in child widgets. Use VoidCallback for simple taps and ValueChanged (or custom function types) when you need to pass data back. This keeps child widgets independent — they report events without knowing what happens next.
- Widget Slots Pattern — Accept Widget parameters to let consumers customize specific parts of your widget. This 'slot' pattern is how Flutter's own Scaffold works — it has slots for appBar, body, floatingActionButton, etc. Use it to create flexible containers that define structure while letting content vary.
- Private Helper Methods — For small pieces of UI that are only used within one widget and do not need their own state or parameters, extract them as private methods (starting with underscore). This is simpler than creating a whole new widget class. But if the piece is reused elsewhere or has complex logic, promote it to a separate widget.
- Builder Pattern for Complex Widgets — For widgets with many optional parameters, consider a builder pattern or use named constructors to provide common configurations. This makes the API cleaner when there are many variants of the same widget — like a button that can be primary, secondary, or destructive.
Code example
import 'package:flutter/material.dart';
class StatusBadge extends StatelessWidget {
final String label;
final StatusType type;
const StatusBadge({
super.key,
required this.label,
this.type = StatusType.info,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: _backgroundColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _borderColor, width: 1),
),
child: Text(
label,
style: TextStyle(
color: _textColor,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
Color get _backgroundColor {
switch (type) {
case StatusType.success:
return Colors.green.shade50;
case StatusType.warning:
return Colors.orange.shade50;
case StatusType.error:
return Colors.red.shade50;
case StatusType.info:
return Colors.blue.shade50;
}
}
Color get _textColor {
switch (type) {
case StatusType.success:
return Colors.green.shade700;
case StatusType.warning:
return Colors.orange.shade700;
case StatusType.error:
return Colors.red.shade700;
case StatusType.info:
return Colors.blue.shade700;
}
}
Color get _borderColor {
switch (type) {
case StatusType.success:
return Colors.green.shade200;
case StatusType.warning:
return Colors.orange.shade200;
case StatusType.error:
return Colors.red.shade200;
case StatusType.info:
return Colors.blue.shade200;
}
}
}
enum StatusType { success, warning, error, info }
// Usage:
// StatusBadge(label: 'Active', type: StatusType.success)
// StatusBadge(label: 'Pending', type: StatusType.warning)
// StatusBadge(label: 'Failed', type: StatusType.error)Line-by-line walkthrough
- 1. Import the Material package
- 2.
- 3. Define StatusBadge as a StatelessWidget
- 4. Required label text to display
- 5. StatusType controls the color scheme, defaults to info
- 6.
- 7. Constructor with required label and optional type
- 8.
- 9.
- 10. Build method returns the styled badge
- 11. Container wraps the text with padding and decoration
- 12. Symmetric padding: 12px horizontal, 6px vertical
- 13.
- 14. BoxDecoration for the pill-shaped badge
- 15. Background color from the private getter based on type
- 16. 16px border radius creates the pill shape
- 17. Thin border using the border color getter
- 18.
- 19. Text displays the label
- 20. Styled with color from getter, small font, semibold weight
- 21.
- 22.
- 23.
- 24.
- 25. Private getter returns background color based on StatusType
- 26. Switch on the type enum
- 27. Green for success
- 28. Orange for warning
- 29. Red for error
- 30. Blue for info
- 31.
- 32.
- 33. Private getter returns text color (darker shade)
- 34. Same switch pattern with shade700 for contrast
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43. Private getter returns border color (lighter shade)
- 44. Same switch pattern with shade200 for subtle borders
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53. Define the StatusType enum with four values
- 54.
- 55. Usage examples showing different badge configurations
- 56. Success badge in green
- 57. Warning badge in orange
- 58. Error badge in red
Spot the bug
class MyButton extends StatelessWidget {
final String label;
final onPressed;
const MyButton({required this.label, this.onPressed});
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: onPressed,
child: Text(label),
);
}
}Need a hint?
Look at the onPressed parameter declaration. What is missing?
Show answer
The onPressed parameter is missing its type annotation. Without a type, it is implicitly 'dynamic' which defeats Dart's type safety. Fix: declare it as 'final VoidCallback? onPressed;' so Dart knows it is an optional function that takes no arguments and returns void. Also add super.key to the constructor.
Explain like I'm 5
Imagine you love drawing robots. Instead of drawing the same robot head from scratch every time (two circles for eyes, a rectangle for the mouth, antennas), you create a rubber stamp of a robot head. Now you just stamp it whenever you need one! But your stamp is special — you can choose the eye color and whether it smiles or frowns by passing in options. That is a custom widget: a reusable stamp with adjustable settings that you can use anywhere in your app.
Fun fact
Flutter's official style guide recommends that any build method longer than about 30 lines should be split into smaller widgets. The Flutter framework itself is built this way — Scaffold is composed of AppBar, Body, FloatingActionButton, Drawer, and BottomNavigationBar, each of which is its own complex widget!
Hands-on challenge
Create a reusable NotificationCard widget that accepts: an icon Widget slot, a title String, an optional subtitle String, an optional trailing Widget slot, an onTap VoidCallback, and a NotificationType enum (info, success, warning, error) that changes the card's left border color. Then use it three times on a screen with different configurations.
More resources
- Creating Reusable Widgets (Flutter Official)
- Flutter Widget Composition (Flutter Official)
- Dart Constructors (Dart Official)