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:

WidgetLookUse it for
FilledButtonSolid colorThe main action: Sign in, Pay, Save
FilledButton.tonalSoft colorImportant but secondary actions
ElevatedButtonLight fill with shadowWhen the button sits on a busy background
OutlinedButtonBorder onlySecondary actions: Cancel, Learn more
TextButtonText onlyLow-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: null while loading.

Keep reading

More articles