Buttons
Flutter ships several button types — FilledButton, OutlinedButton, TextButton, IconButton, ElevatedButton. Each maps to a Material 3 emphasis level; pair with onPressed, style, and loading state.
Filled, outlined, text, icon, custom
EXAMPLE
import 'package:flutter/material.dart';
// 1) The Material 3 button hierarchy
// FilledButton — high emphasis primary action
// FilledButton.tonal — lower-emphasis primary
// OutlinedButton — secondary / dangerous actions
// TextButton — tertiary, low emphasis
// ElevatedButton — legacy / when you really want a shadow
// IconButton — icon-only
class ButtonShowcase extends StatelessWidget {
@@override Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FilledButton(
onPressed: () => print('save'),
child: const Text('Save'),
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () => print('schedule'),
child: const Text('Schedule'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => print('cancel'),
child: const Text('Cancel'),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => print('learn more'),
child: const Text('Learn more'),
),
const SizedBox(height: 8),
IconButton(
onPressed: () => print('open'),
icon: const Icon(Icons.open_in_new),
tooltip: 'Open',
),
],
);
}
}
// 2) Buttons with icons
FilledButton.icon(
onPressed: () {},
icon: const Icon(Icons.save),
label: const Text('Save'),
);
OutlinedButton.icon(
onPressed: () {},
icon: const Icon(Icons.share),
label: const Text('Share'),
);
TextButton.icon(
onPressed: () {},
icon: const Icon(Icons.add),
label: const Text('Add'),
);
// 3) Disabled state — pass null to onPressed
FilledButton(onPressed: null, child: const Text('Disabled'));
FilledButton(
onPressed: isValid ? _submit : null,
child: const Text('Submit'),
);
// 4) Loading state — show a spinner inside the button
class LoadingButton extends StatelessWidget {
final bool busy;
final VoidCallback? onPressed;
final Widget child;
const LoadingButton({
super.key,
required this.busy,
required this.onPressed,
required this.child,
});
@@override
Widget build(BuildContext context) {
return FilledButton(
onPressed: busy ? null : onPressed,
child: busy
? const SizedBox(
height: 18, width: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: child,
);
}
}
// Use:
LoadingButton(busy: _saving, onPressed: _save, child: const Text('Save'));
// 5) Styling — ButtonStyle
FilledButton(
onPressed: () {},
style: FilledButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
child: const Text('Custom'),
);
// MaterialStateProperty for per-state styling
FilledButton(
onPressed: () {},
style: ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.disabled)) return Colors.grey;
if (states.contains(MaterialState.pressed)) return Colors.deepPurple.shade700;
if (states.contains(MaterialState.hovered)) return Colors.deepPurple.shade600;
return Colors.deepPurple;
}),
foregroundColor: MaterialStateProperty.all(Colors.white),
padding: MaterialStateProperty.all(const EdgeInsets.symmetric(horizontal: 24, vertical: 12)),
),
child: const Text('State-styled'),
);
// 6) Theme-wide button styles
// In ThemeData:
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(side: const BorderSide(width: 1.5)),
),
// 7) Full-width button
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _submit,
child: const Text('Submit'),
),
);
// 8) Row of buttons (form footer)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(onPressed: _cancel, child: const Text('Cancel')),
const SizedBox(width: 8),
FilledButton(onPressed: _save, child: const Text('Save')),
],
);
// 9) Floating action button (FAB)
FloatingActionButton(
onPressed: _newItem,
tooltip: 'Add',
child: const Icon(Icons.add),
);
FloatingActionButton.extended(
onPressed: _newItem,
icon: const Icon(Icons.add),
label: const Text('New post'),
);
// 10) Icon button variants
IconButton.filled(onPressed: () {}, icon: const Icon(Icons.share));
IconButton.filledTonal(onPressed: () {}, icon: const Icon(Icons.share));
IconButton.outlined(onPressed: () {}, icon: const Icon(Icons.share));
// 11) Toggle button (selected vs not)
class ToggleStar extends StatefulWidget {
@@override State<ToggleStar> createState() => _ToggleStarState();
}
class _ToggleStarState extends State<ToggleStar> {
bool _starred = false;
@@override Widget build(BuildContext context) {
return IconButton(
isSelected: _starred,
selectedIcon: const Icon(Icons.star, color: Colors.amber),
icon: const Icon(Icons.star_border),
onPressed: () => setState(() => _starred = !_starred),
);
}
}
// 12) ButtonBar — convenient row of dialog buttons
ButtonBar(
alignment: MainAxisAlignment.end,
children: [
TextButton(onPressed: _cancel, child: const Text('Cancel')),
FilledButton(onPressed: _delete, child: const Text('Delete')),
],
);
// 13) Custom button — InkWell / GestureDetector
class CustomButton extends StatelessWidget {
final VoidCallback onPressed;
final Widget child;
const CustomButton({super.key, required this.onPressed, required this.child});
@@override
Widget build(BuildContext context) {
return Material(
color: Colors.deepPurple,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: DefaultTextStyle(
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
child: child,
),
),
),
);
}
}
// 14) Long press
GestureDetector(
onLongPress: () => print('long pressed'),
child: TextButton(
onPressed: () => print('tapped'),
child: const Text('Tap or long-press'),
),
);
// Or use InkWell:
InkWell(
onTap: _tap,
onLongPress: _longPress,
child: const Padding(
padding: EdgeInsets.all(12),
child: Text('Action'),
),
);
// 15) Accessibility
// IconButton: always set tooltip (becomes screen-reader label)
// Buttons: 48x48 dp minimum tap target (Material guidelines)
// Loading state: disabled to prevent double-submission
// Don't override 'disabled' opacity unless you keep contrast accessible
// 16) Common bugs
// • Passing onPressed: () {} when you should pass null for disabled
// • Using ElevatedButton for Material 3 apps — FilledButton is the modern primary
// • Forgetting tooltip on IconButton — accessibility issue
// • Wrapping button + InkWell — InkWell handles its own ripple
// • Async onPressed without checking mounted → setState after dispose
FilledButton(
onPressed: () async {
setState(() => _loading = true);
try {
await api.save(data);
} finally {
if (mounted) setState(() => _loading = false);
}
},
child: _loading ? CircularProgressIndicator() : const Text('Save'),
);
Why it matters
Default to Material 3 buttons: FilledButton for primary, OutlinedButton for secondary, TextButton for tertiary. Customise via theme not per-call — the whole app stays consistent without re-styling every button.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
ElevatedButton(onPressed: () {}, child: const Text('Save'))
TextButton(onPressed: () {}, child: const Text('Cancel'))
OutlinedButton(onPressed: () {}, child: const Text('More'))
Try it Yourself »
Discussion
Loading…