Hey there! I totally get what you’re going through. You’ve probably noticed yourself writing the same button styles over and over—same colors, same padding, same icon positions, same onPressed logic patterns—and thinking, “There’s got to be a better way.” Well, you’re in the right place. Let me walk you through exactly how to handle this in Flutter, because honestly, it’s one of those things that will save you hours once you get it right.
The Problem: Why We End Up Repeating Ourselves
Let me paint you a picture. You’re building a screen with five buttons. Each one needs that primary color background, white text, rounded corners, a specific padding, and maybe an icon on the left. So you write something like this for each button:
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
onPressed: () => _doSomething(),
child: const Text('Click Me'),
)
You do this five times. Then your designer says, “Actually, let’s change the blue to a slightly darker blue.” And you’re like… no. You have to go find all five instances and change them. That’s not just annoying—it’s a maintenance nightmare. And if you need to change behavior too, like adding analytics tracking to every button click, you’re digging through code again.
The Simplest Solution: A Custom Widget
Before we get into the fancy “inherited” stuff, let me show you the most straightforward approach. Create your own button widget. This isn’t reinventing the wheel—it’s just giving your button a name.
class PrimaryButton extends StatelessWidget {
final String label;
final VoidCallback onPressed;
final IconData? icon;
final Color? backgroundColor;
final Color? foregroundColor;
final double? minWidth;
final double? minHeight;
const PrimaryButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
this.backgroundColor,
this.foregroundColor,
this.minWidth,
this.minHeight,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: minWidth,
height: minHeight ?? 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor ?? Theme.of(context).primaryColor,
foregroundColor: foregroundColor ?? Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
minimumSize: Size(minWidth ?? 88, minHeight ?? 48),
),
onPressed: onPressed,
child: Row(
mainAxisSize: mainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 18),
const SizedBox(width: 8),
],
Text(label),
],
),
),
);
}
}
Now every time you want that button style, you just write:
PrimaryButton(
label: 'Save Changes',
icon: Icons.save,
onPressed: () => _saveChanges(),
)
Clean, right? One line of custom code, and you’ve got a reusable button everywhere. But here’s the thing—this approach has limits. What if you want different screens to have slightly different primary colors? What if you need to override the style in specific contexts? That’s where InheritedWidget comes in.
Going Deeper: Using InheritedWidget for Dynamic Theme-Level Styling
InheritedWidget is Flutter’s built-in mechanism for passing data down the widget tree. It’s what themes use under the hood. Think of it as a way to say, “Hey, every button below me in the tree should know about these default styles unless I tell them otherwise.”
Let me build you a real-world example. We’ll create an AppButtonTheme that holds default button configurations, and then a StyledButton widget that reads from it.
// First, define what a button theme config looks like
@immutable
class AppButtonThemeData {
final Color primaryBackgroundColor;
final Color primaryForegroundColor;
final Color secondaryBackgroundColor;
final Color secondaryForegroundColor;
final ShapeBorder primaryShape;
final ShapeBorder secondaryShape;
final EdgeInsets primaryPadding;
final EdgeInsets secondaryPadding;
final TextTheme buttonTextTheme;
final double cornerRadius;
const AppButtonThemeData({
this.primaryBackgroundColor = Colors.blue,
this.primaryForegroundColor = Colors.white,
this.secondaryBackgroundColor = Colors.grey,
this.secondaryForegroundColor = Colors.black,
this.primaryShape = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
this.secondaryShape = RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
this.primaryPadding = const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
this.secondaryPadding = const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
this.cornerRadius = 8.0,
});
AppButtonThemeData copyWith({
Color? primaryBackgroundColor,
Color? primaryForegroundColor,
Color? secondaryBackgroundColor,
Color? secondaryForegroundColor,
ShapeBorder? primaryShape,
ShapeBorder? secondaryShape,
EdgeInsets? primaryPadding,
EdgeInsets? secondaryPadding,
double? cornerRadius,
}) {
return AppButtonThemeData(
primaryBackgroundColor: primaryBackgroundColor ?? this.primaryBackgroundColor,
primaryForegroundColor: primaryForegroundColor ?? this.primaryForegroundColor,
secondaryBackgroundColor: secondaryBackgroundColor ?? this.secondaryBackgroundColor,
secondaryForegroundColor: secondaryForegroundColor ?? this.secondaryForegroundColor,
primaryShape: primaryShape ?? this.primaryShape,
secondaryShape: secondaryShape ?? this.secondaryShape,
primaryPadding: primaryPadding ?? this.primaryPadding,
secondaryPadding: secondaryPadding ?? this.secondaryPadding,
cornerRadius: cornerRadius ?? this.cornerRadius,
);
}
}
// Now the InheritedWidget itself
class AppButtonTheme extends InheritedWidget {
final AppButtonThemeData data;
const AppButtonTheme({
super.key,
required this.data,
required super.child,
});
// This is the magic method—widgets use this to find the nearest theme
static AppButtonThemeData of(BuildContext context) {
final theme = context.dependOnInheritedWidgetOfExactType<AppButtonTheme>();
assert(theme != null, 'No AppButtonTheme found in the widget tree');
return theme!.data;
}
@override
bool updateShouldNotify(AppButtonTheme oldWidget) {
// Only rebuild when the data actually changes
return data != oldWidget.data;
}
}
Now here’s where it gets really useful. Let’s create a StyledButton that automatically picks up the theme:
class StyledButton extends StatelessWidget {
final String label;
final VoidCallback onPressed;
final IconData? icon;
final ButtonType type; // primary or secondary
final bool isLoading;
const StyledButton({
super.key,
required this.label,
required this.onPressed,
this.icon,
this.type = ButtonType.primary,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
final theme = AppButtonTheme.of(context);
final isPrimary = type == ButtonType.primary;
final backgroundColor = isPrimary
? theme.primaryBackgroundColor
: theme.secondaryBackgroundColor;
final foregroundColor = isPrimary
? theme.primaryForegroundColor
: theme.secondaryForegroundColor;
final padding = isPrimary ? theme.primaryPadding : theme.secondaryPadding;
final shape = isPrimary ? theme.primaryShape : theme.secondaryShape;
return SizedBox(
height: 48,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
padding: padding,
shape: shape,
minimumSize: const Size(88, 48),
),
onPressed: isLoading ? null : onPressed,
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Row(
mainAxisSize: mainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 18),
const SizedBox(width: 8),
],
Text(
label,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: foregroundColor,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
enum ButtonType { primary, secondary }
And in your main app, you wrap everything with the theme:
void main() {
runApp(
AppButtonTheme(
data: const AppButtonThemeData(
primaryBackgroundColor: Color(0xFF1976D2),
primaryForegroundColor: Colors.white,
secondaryBackgroundColor: Colors.grey,
secondaryForegroundColor: Colors.black,
cornerRadius: 12,
),
child: const MyApp(),
),
);
}
Now anywhere in your app, you just write:
StyledButton(
label: 'Continue',
icon: Icons.arrow_forward,
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const NextPage())),
)
StyledButton(
label: 'Cancel',
type: ButtonType.secondary,
onPressed: () => Navigator.pop(context),
)
No repetitive style code. No copy-pasting. And if you want to change the primary color across the entire app, you change it in one place inside AppButtonTheme.
Handling Behavior Reuse: Wrapping onPressed with Common Logic
You mentioned behavior too, not just styles. Let me show you a pattern I use all the time. Sometimes you want every button to do the same thing before running its actual action—like logging, analytics, or a loading state.
// A helper that wraps any onPressed with common behavior
class ButtonBehaviorController {
final Function(String actionName)? onBeforeClick;
final Function(String actionName)? onAfterClick;
final Function(String actionName, Exception e)? onError;
const ButtonBehaviorController({
this.onBeforeClick,
this.onAfterClick,
this.onError,
});
VoidCallback wrap(VoidCallback originalCallback, {String actionName = 'button'}) {
return () async {
try {
if (onBeforeClick != null) {
onBeforeClick!(actionName);
}
await originalCallback();
if (onAfterClick != null) {
onAfterClick!(actionName);
}
} catch (e, stackTrace) {
if (onError != null) {
onError!(actionName, e as Exception);
}
rethrow;
}
};
}
}
Then you use it like this:
final buttonBehavior = ButtonBehaviorController(
onBeforeClick: (name) => print('Button clicked: $name'),
onAfterClick: (name) => debugPrint('Button finished: $name'),
onError: (name, error) => debugPrint('Button error on $name: $error'),
);
// Then in your button:
StyledButton(
label: 'Submit',
icon: Icons.check,
onPressed: buttonBehavior.wrap(
() async {
await _submitForm();
},
actionName: 'submit_button',
),
)
This keeps your business logic separate from your button wiring, and you can reuse the same controller across your entire app.
Real-World Example: A Complete Screen Using Everything Together
Let me tie this all together with a practical example you could actually drop into a project. This is the kind of thing I build for clients all the time:
”`dart import ‘package:flutter/material.dart’;
// ─── Theme Data ─────────────────────────────────────────────── @immutable class ButtonThemeConfig { final Color primaryBg; final Color primaryFg; final Color secondaryBg; final Color secondaryFg; final Color disabledBg; final Color disabledFg; final double borderRadius; final EdgeInsets standardPadding;
const ButtonThemeConfig({
this.primaryBg = Color(0xFF2196F3),
this.primaryFg = Colors.white,
this.secondaryBg = Color(0xFFEEEEEE),
this.secondaryFg = Color(0xFF2196F3),
this.disabledBg = Color(0xFFBDBDBD),
this.disabledFg = Colors.white,
this.borderRadius = 10.0,
this.standardPadding = const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
});
ButtonThemeConfig copyWith({
Color? primaryBg,
Color? primaryFg,
Color? secondaryBg,
Color? secondaryFg,
Color? disabledBg,
Color? disabledFg,
double? borderRadius,
EdgeInsets? standardPadding,
}) {
return ButtonThemeConfig(
primaryBg: primaryBg ?? this.primaryBg,
primaryFg: primaryFg ?? this.primaryFg,
secondaryBg: secondaryBg ?? this.secondaryBg,
secondaryFg: secondaryFg ?? this.secondaryFg,
disabledBg: disabledBg ?? this.disabledBg,
disabledFg: disabledFg ?? this.disabledFg,
borderRadius: borderRadius ?? this.borderRadius,
standardPadding: standardPadding ?? this.standardPadding,
);
} }
class AppButtonTheme extends InheritedWidget { final ButtonThemeConfig config;
const AppButtonTheme({
super.key,
required this.config,
required super.child,
});
static ButtonThemeConfig of(BuildContext context) {
final theme = context.dependOnInheritedWidgetOfExactType<AppButtonTheme>();
return theme?.config ?? const ButtonThemeConfig();
}
@override bool updateShouldNotify(AppButtonTheme oldWidget) => config != oldWidget.config; }
// ─── The Reusable Button ───────────────────────────────────── enum ButtonStyleType { primary, secondary, danger, ghost }
class AppButton extends StatelessWidget { final String label; final VoidCallback? onPressed; final IconData? icon; final ButtonStyleType styleType; final bool isLoading; final bool fullWidth; final double? height; final Widget? child;
const AppButton({
super.key,
required this.label,
this.onPressed,
this.icon,
this.styleType = ButtonStyleType.primary,
this.isLoading = false,
this.fullWidth = false,
this.height,
this.child,
});
@override Widget build(BuildContext context) {
final config = AppButtonTheme.of(context);
final isDisabled = onPressed == null || isLoading;
// Pick colors based on style type
late Color bgColor;
late Color fgColor;
switch (styleType) {
case ButtonStyleType.primary:
bgColor = config.primaryBg;
fgColor = config.primaryFg;
break;
case ButtonStyleType.secondary:
bgColor = config.secondaryBg;
fgColor = config.secondaryFg;
break;
case ButtonStyleType.danger:
bgColor = const Color(0xFFE53935);
fgColor = Colors.white;
break;
case ButtonStyleType.ghost:
bgColor = Colors.transparent;
fgColor = config.primaryFg;
break;
}
if (isDisabled) {
bgColor = config.disabledBg;
fgColor = config.disabledFg;
}
return SizedBox(
width: fullWidth ? double.infinity : null,
height: height ?? 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: bgColor,
foregroundColor: fgColor,
padding: config.standardPadding,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(config.borderRadius),
),
elevation: styleType == ButtonStyleType.primary && !isDisabled ? 2 : 0,
),
onPressed: isDisabled ? null : onPressed,
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: child ??
Row(
mainAxisSize: mainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 20),
const SizedBox(width: 8),
],
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
],
),
),
);
} }
// ─── Usage Example Screen ──────────────────────────────────── class DemoScreen extends StatelessWidget { const DemoScreen({super.key});
@override Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Reusable Buttons Demo')),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.st