Flutter Buttons: Custom Styles, Gradient Buttons and Loading States
Style Flutter buttons the right way. Learn FilledButton, ElevatedButton, OutlinedButton and TextButton, customise shape and colors with styleFrom, and build gradient and loading buttons.
By Yaqoob Developer · · 4 min read
- #Flutter
- #Buttons
- #Flutter UI
- #Material 3
- #Beginners
Buttons are on every screen of every app, and nothing makes a design look amateur faster than a default button with the wrong color and size.
In this guide you will learn:
- Which button widget to use and when
- How to change color, size, shape and text with styleFrom
- How to style every button in the app at once
- How to build a gradient button
- How to add a loading state and an icon
The Material 3 button family
Flutter has five main buttons. They do the same job but signal different importance:
| Widget | Look | Use it for |
|---|---|---|
| FilledButton | Solid color | The main action: Sign in, Pay, Save |
| FilledButton.tonal | Soft color | Important but secondary actions |
| ElevatedButton | Light fill with shadow | When the button sits on a busy background |
| OutlinedButton | Border only | Secondary actions: Cancel, Learn more |
| TextButton | Text only | Low-priority actions: Forgot password? |
A good screen has one filled button at most. If everything is bold, nothing stands out.
FilledButton(onPressed: () {}, child: const Text('Sign in')),
OutlinedButton(onPressed: () {}, child: const Text('Create account')),
TextButton(onPressed: () {}, child: const Text('Forgot password?')),
A button with onPressed: null is shown as disabled. That is the right way to disable a button, not hiding it or changing its color yourself.
Customising one button with styleFrom
Every button has a styleFrom helper:
FilledButton(
onPressed: () {},
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF1E1E2E),
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
child: const Text('Continue'),
)
- foregroundColor sets the text and icon color.
- minimumSize: Size.fromHeight(56) makes the button full width and 56 pixels tall. Full-width buttons are standard on mobile forms.
- shape controls the corners. Use
StadiumBorder()for a pill shape.
For an OutlinedButton, change the border with side:
OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white24, width: 1.5),
)
Style every button at once
Repeating styleFrom on every button is how apps end up with ten slightly different buttons. Set the style once in your theme instead:
ThemeData(
colorSchemeSeed: Colors.deepPurple,
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
)
Now every FilledButton(...) in the app looks the same without any extra code. Change the radius once, and the whole app updates.
Different colors for pressed, hovered and disabled
styleFrom handles the common cases. For full control of each state, use ButtonStyle with WidgetStateProperty:
ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) return Colors.grey.shade300;
if (states.contains(WidgetState.pressed)) return Colors.indigo.shade700;
return Colors.indigo;
}),
)
Older tutorials use MaterialStateProperty and MaterialState. Those were renamed to WidgetStateProperty and WidgetState; use the new names in new code.
Buttons with icons
Every button has an .icon constructor:
FilledButton.icon(
onPressed: () {},
icon: const Icon(Icons.download),
label: const Text('Download'),
)
For social login buttons, use OutlinedButton.icon with the brand logo as an Image.asset in the icon slot.
A gradient button
Material buttons don't take a gradient directly. The clean way is to draw the gradient behind a transparent button, so you keep the ripple effect, focus and accessibility:
class GradientButton extends StatelessWidget {
const GradientButton({
super.key,
required this.onPressed,
required this.child,
this.colors = const [Color(0xFF7F5AF0), Color(0xFF2CB1BC)],
});
final VoidCallback? onPressed;
final Widget child;
final List<Color> colors;
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(16);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: radius,
gradient: onPressed == null
? null
: LinearGradient(colors: colors),
color: onPressed == null ? Colors.grey.shade400 : null,
),
child: FilledButton(
onPressed: onPressed,
style: FilledButton.styleFrom(
backgroundColor: Colors.transparent,
disabledBackgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
foregroundColor: Colors.white,
disabledForegroundColor: Colors.white70,
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(borderRadius: radius),
),
child: child,
),
);
}
}
Use it like any button:
GradientButton(
onPressed: () {},
child: const Text('Get started'),
)
Gradient buttons are a big part of the look in designs like the travel app sign-in UI and the music app login screen.
A loading button
When the button starts a network request, show progress and block double taps:
class LoadingButton extends StatelessWidget {
const LoadingButton({
super.key,
required this.loading,
required this.onPressed,
required this.label,
});
final bool loading;
final VoidCallback? onPressed;
final String label;
@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: loading ? null : onPressed,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: loading
? const SizedBox.square(
key: ValueKey('spinner'),
dimension: 22,
child: CircularProgressIndicator(strokeWidth: 2.5),
)
: Text(label, key: const ValueKey('label')),
),
);
}
}
The AnimatedSwitcher gives a smooth fade between the label and the spinner. Read more about it in Flutter animations for beginners. To see this button in a full sign-in flow, check Flutter form validation.
Common mistakes
Wrapping a button in GestureDetector. Buttons already handle taps. An extra GestureDetector can swallow the tap or remove the ripple.
Using Container with onTap as a button. It has no ripple, no focus outline for keyboards and no disabled state. Use a real button, or InkWell if you need a custom shape.
Setting width with a SizedBox on every button. Use minimumSize: Size.fromHeight(...) in the theme for full-width buttons.
Text too small to tap. Keep buttons at least 48 pixels tall. Apple and Google both recommend touch targets around this size.
Summary
- Pick the button by importance: FilledButton for the main action, Outlined and Text buttons for the rest.
- Customise with styleFrom, and set app-wide styles in ThemeData.
- Use WidgetStateProperty for per-state colors.
- Build gradients with a DecoratedBox behind a transparent button, and disable buttons with
onPressed: nullwhile loading.
Keep reading
More articles
· 5 min read
Provider vs Riverpod vs Bloc: Which Flutter State Management to Choose?
An honest comparison of Provider, Riverpod and Bloc for Flutter. The same counter-and-cart example in all three, the pros and cons of each, and a simple way to decide for your app.
· 5 min read
Flutter Splash Screen and App Icon: The Complete Setup Guide
Replace the default Flutter icon and white launch screen. Set up a native splash screen with flutter_native_splash, including Android 12, and add every iOS and Android app icon size the easy way.
· 5 min read
Parse JSON in Flutter: Create Dart Model Classes (fromJson and toJson)
Stop using Map<String, dynamic> everywhere. Learn how to turn JSON into Dart model classes with fromJson and toJson, handle nested objects, lists and null values, and generate models instantly.