tldr
copy one file into your project, use it. no setup, no init call.
AppButton(text: "Continue", onPressed: _submit).
three types, three sizes, loading and disabled states, leading/trailing icons. press feedback runs on a spring, so scale and fill land together.
starting the demo
install
copy one file
grab it and drop it in at lib/app_button.dart.
// A self-contained, plug-and-play button. Drop this single file into any
// Flutter project — no packages, no DI, no widget wrapping.
//
// 1. Use it anywhere:
// AppButton(text: "Continue", onPressed: _submit)
// AppButton(text: "Cancel", type: AppButtonType.secondary)
// AppButton(text: "Saving", isLoading: true, size: AppButtonSize.md)
//
// 2. Restyle once at startup — or never, the defaults stand on their own:
// AppButton.configure(light: AppButtonColors.light.copyWith(accent: myBlue));
//
// 3. Press feedback runs on a spring, not a curve. Scale and fill are
// driven by one animation so they land together.
import "package:flutter/material.dart";
import "package:flutter/physics.dart";
// ---------------------------------------------------------------------------
// Public enums
// ---------------------------------------------------------------------------
/// Visual weight of a button — sets fill, border, and press feedback.
enum AppButtonType {
/// Accent-filled solid. The one call to action on a screen.
primary,
/// Outlined and transparent. Secondary actions beside a primary.
secondary,
/// Bare text. Low-emphasis actions — dismiss, learn more, skip.
tertiary,
}
/// Size step — sets padding and text style.
enum AppButtonSize {
/// Compact, for dense rows and inline actions.
sm,
/// The everyday size.
md,
/// Full-height, for primary actions at the bottom of a screen.
lg,
}
/// Interaction state a color token is resolved against.
enum AppButtonState {
/// Enabled and untouched.
defaultState,
/// Non-interactive — disabled, or busy with [AppButton.isLoading].
disabled,
}
// ---------------------------------------------------------------------------
// Theme
// ---------------------------------------------------------------------------
/// Color tokens for a button palette.
///
/// Use [light] and [dark] presets, or build your own and pass them to
/// [AppButton.configure].
@immutable
class AppButtonColors {
/// Creates a button color palette.
const AppButtonColors({
required this.foreground,
required this.muted,
required this.surface,
required this.accent,
required this.accentForeground,
required this.accentStrong,
required this.border,
required this.disabled,
});
/// Label on secondary and tertiary buttons.
final Color foreground;
/// Label on a disabled button of any type.
final Color muted;
/// Pressed fill for the flat types, which have no resting background.
final Color surface;
/// Primary fill.
final Color accent;
/// Label on a primary button.
final Color accentForeground;
/// Pressed primary fill.
final Color accentStrong;
/// Outline on a secondary button.
final Color border;
/// Fill on a disabled primary or secondary button.
final Color disabled;
/// Default palette for light mode.
static const AppButtonColors light = AppButtonColors(
foreground: Color(0xFF1C2229),
muted: Color(0xFF6C7278),
surface: Color(0xFFEAEFF5),
accent: Color(0xFF8000FF),
accentForeground: Color(0xFFFFFFFF),
accentStrong: Color(0xFF5C00B8),
border: Color(0xFFD8DFE6),
disabled: Color(0xFFB3B8BE),
);
/// Default palette for dark mode.
static const AppButtonColors dark = AppButtonColors(
foreground: Color(0xFFDADEE3),
muted: Color(0xFF8B9095),
surface: Color(0xFF1D2227),
accent: Color(0xFFD7F881),
accentForeground: Color(0xFF0E1216),
accentStrong: Color(0xFFE8FFA8),
border: Color(0xFF353B42),
disabled: Color(0xFF484E54),
);
/// Resting fill for [type] in [state], or null when the type has none.
Color? backgroundFor(AppButtonType type, AppButtonState state) {
if (state == AppButtonState.disabled) {
return switch (type) {
AppButtonType.primary || AppButtonType.secondary => disabled,
AppButtonType.tertiary => null,
};
}
return switch (type) {
AppButtonType.primary => accent,
AppButtonType.secondary || AppButtonType.tertiary => null,
};
}
/// Label color for [type] in [state].
Color foregroundFor(AppButtonType type, AppButtonState state) {
if (state == AppButtonState.disabled) return muted;
return switch (type) {
AppButtonType.primary => accentForeground,
AppButtonType.secondary || AppButtonType.tertiary => foreground,
};
}
/// Outline for [type] in [state], or null when the type has none.
Color? borderFor(AppButtonType type, AppButtonState state) {
return switch (type) {
AppButtonType.primary || AppButtonType.tertiary => null,
AppButtonType.secondary =>
state == AppButtonState.disabled ? disabled : border,
};
}
/// Returns a copy with the given fields replaced.
AppButtonColors copyWith({
Color? foreground,
Color? muted,
Color? surface,
Color? accent,
Color? accentForeground,
Color? accentStrong,
Color? border,
Color? disabled,
}) {
return AppButtonColors(
foreground: foreground ?? this.foreground,
muted: muted ?? this.muted,
surface: surface ?? this.surface,
accent: accent ?? this.accent,
accentForeground: accentForeground ?? this.accentForeground,
accentStrong: accentStrong ?? this.accentStrong,
border: border ?? this.border,
disabled: disabled ?? this.disabled,
);
}
}
/// Typography and metrics per [AppButtonSize].
@immutable
class AppButtonSizing {
/// Creates sizing settings.
const AppButtonSizing({
this.fontFamily,
this.sm = const TextStyle(
fontSize: 12,
height: 16 / 12,
fontWeight: FontWeight.w500,
),
this.md = const TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w500,
),
this.lg = const TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w500,
),
this.smPadding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
this.mdPadding = const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
this.lgPadding = const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
this.borderRadius = 24,
this.gap = 8,
});
static const Object _unset = Object();
/// Applied to every style unless a style names its own family.
///
/// Defaults to the ambient font so a fresh project needs no asset setup.
final String? fontFamily;
/// Label style for [AppButtonSize.sm].
final TextStyle sm;
/// Label style for [AppButtonSize.md].
final TextStyle md;
/// Label style for [AppButtonSize.lg].
final TextStyle lg;
/// Padding for [AppButtonSize.sm].
final EdgeInsets smPadding;
/// Padding for [AppButtonSize.md].
final EdgeInsets mdPadding;
/// Padding for [AppButtonSize.lg].
final EdgeInsets lgPadding;
/// Corner radius of the button shape.
final double borderRadius;
/// Space between icon and label.
final double gap;
/// Resolves the label style for [size] with [color] and [fontFamily].
TextStyle textFor(AppButtonSize size, Color color) {
final TextStyle style = switch (size) {
AppButtonSize.sm => sm,
AppButtonSize.md => md,
AppButtonSize.lg => lg,
};
return style.copyWith(
color: color,
fontFamily: style.fontFamily ?? fontFamily,
);
}
/// Returns the padding for [size].
EdgeInsets paddingFor(AppButtonSize size) => switch (size) {
AppButtonSize.sm => smPadding,
AppButtonSize.md => mdPadding,
AppButtonSize.lg => lgPadding,
};
/// Returns a copy with the given fields replaced.
///
/// Pass `fontFamily: null` explicitly to clear a previously set family.
AppButtonSizing copyWith({
Object? fontFamily = _unset,
TextStyle? sm,
TextStyle? md,
TextStyle? lg,
EdgeInsets? smPadding,
EdgeInsets? mdPadding,
EdgeInsets? lgPadding,
double? borderRadius,
double? gap,
}) {
return AppButtonSizing(
fontFamily: fontFamily == _unset
? this.fontFamily
: fontFamily as String?,
sm: sm ?? this.sm,
md: md ?? this.md,
lg: lg ?? this.lg,
smPadding: smPadding ?? this.smPadding,
mdPadding: mdPadding ?? this.mdPadding,
lgPadding: lgPadding ?? this.lgPadding,
borderRadius: borderRadius ?? this.borderRadius,
gap: gap ?? this.gap,
);
}
}
/// Global button configuration — colors, sizing, and press feel.
@immutable
class AppButtonTheme {
/// Creates a button theme.
const AppButtonTheme({
this.light = AppButtonColors.light,
this.dark = AppButtonColors.dark,
this.sizing = const AppButtonSizing(),
this.pressScale = 0.03,
this.brightnessResolver,
}) : assert(
pressScale >= 0 && pressScale < 1,
"pressScale must be in [0, 1)",
);
static const Object _unset = Object();
/// Palette used when [brightnessOf] resolves to [Brightness.light].
final AppButtonColors light;
/// Palette used when [brightnessOf] resolves to [Brightness.dark].
final AppButtonColors dark;
/// Typography and metrics per size.
final AppButtonSizing sizing;
/// How far the button shrinks at full press — 0.03 is a 3% dip.
final double pressScale;
/// Escape hatch for apps that drive brightness off something other than the
/// ambient [Theme].
final Brightness Function(BuildContext context)? brightnessResolver;
/// Resolves the current brightness for [context].
Brightness brightnessOf(BuildContext context) =>
brightnessResolver?.call(context) ?? Theme.of(context).brightness;
/// Returns the color palette for [context]'s current brightness.
AppButtonColors colorsOf(BuildContext context) =>
brightnessOf(context) == Brightness.dark ? dark : light;
/// Returns a copy with the given fields replaced.
///
/// Pass `brightnessResolver: null` explicitly to clear a custom resolver.
AppButtonTheme copyWith({
AppButtonColors? light,
AppButtonColors? dark,
AppButtonSizing? sizing,
double? pressScale,
Object? brightnessResolver = _unset,
}) {
return AppButtonTheme(
light: light ?? this.light,
dark: dark ?? this.dark,
sizing: sizing ?? this.sizing,
pressScale: pressScale ?? this.pressScale,
brightnessResolver: brightnessResolver == _unset
? this.brightnessResolver
: brightnessResolver as Brightness Function(BuildContext context)?,
);
}
}
// ---------------------------------------------------------------------------
// Springs
// ---------------------------------------------------------------------------
/// A press is a physical event, so it settles on a spring rather than over a
/// fixed duration. Approximates iOS's smooth interactive spring.
abstract final class _AppButtonSprings {
/// Near-critical (zeta ~0.9) — quick, barely a wobble. A button that
/// visibly bounced under a fingertip would read as loose, not lively.
static const SpringDescription press = SpringDescription(
mass: 1,
stiffness: 480,
damping: 39,
);
}
// ---------------------------------------------------------------------------
// Button
// ---------------------------------------------------------------------------
/// A spring-animated button with three weights and three sizes.
///
/// Requires [text], [leadingIcon], or [trailingIcon]. Pass [onPressed] to
/// enable it; a null callback, [isDisabled], or [isLoading] all render the
/// disabled treatment and swallow taps.
class AppButton extends StatefulWidget {
/// Creates a button.
const AppButton({
super.key,
this.text,
this.onPressed,
this.isLoading = false,
this.isDisabled = false,
this.backgroundColor,
this.foregroundColor,
this.disabledBackgroundColor,
this.borderColor,
this.borderRadius,
this.padding,
this.width,
this.height,
this.leadingIcon,
this.trailingIcon,
this.textStyle,
this.semanticLabel,
this.type = AppButtonType.primary,
this.size = AppButtonSize.lg,
}) : assert(
text != null || leadingIcon != null || trailingIcon != null,
"AppButton requires text or an icon.",
);
static AppButtonTheme _theme = const AppButtonTheme();
/// The active button theme.
static AppButtonTheme get theme => _theme;
/// Restyles every [AppButton] in the app. Safe to call more than once;
/// buttons already on screen pick the change up on their next build.
static void configure({
AppButtonColors? light,
AppButtonColors? dark,
AppButtonSizing? sizing,
double? pressScale,
Object? brightnessResolver = AppButtonTheme._unset,
}) {
_theme = _theme.copyWith(
light: light,
dark: dark,
sizing: sizing,
pressScale: pressScale,
brightnessResolver: brightnessResolver,
);
}
/// Resets the theme to defaults. For use in tests only.
@visibleForTesting
static void debugReset() => _theme = const AppButtonTheme();
/// Label text. Optional when an icon is supplied.
final String? text;
/// Tap handler. A null callback disables the button.
final VoidCallback? onPressed;
/// Swaps the content for a spinner and blocks taps.
final bool isLoading;
/// Renders the disabled treatment and blocks taps.
final bool isDisabled;
/// Overrides the resolved fill.
final Color? backgroundColor;
/// Overrides the resolved label and icon color.
final Color? foregroundColor;
/// Overrides the resolved disabled fill.
final Color? disabledBackgroundColor;
/// Overrides the resolved outline.
final Color? borderColor;
/// Overrides [AppButtonSizing.borderRadius].
final double? borderRadius;
/// Overrides the padding for [size].
final EdgeInsets? padding;
/// Fixed width. Null hugs the content.
final double? width;
/// Fixed height. Null hugs the content.
final double? height;
/// Icon before the label.
final Widget? leadingIcon;
/// Icon after the label. Replaced by the spinner on a loading tertiary.
final Widget? trailingIcon;
/// Overrides the resolved label style. Color is still applied on top.
final TextStyle? textStyle;
/// Screen-reader label. Defaults to [text]; supply one for icon-only buttons.
final String? semanticLabel;
/// Visual weight.
final AppButtonType type;
/// Size step.
final AppButtonSize size;
@override
State<AppButton> createState() => _AppButtonState();
}
class _AppButtonState extends State<AppButton>
with SingleTickerProviderStateMixin {
/// 0 = at rest, 1 = fully pressed. One value drives scale and fill so they
/// arrive together instead of drifting apart on separate curves.
late final AnimationController _press = AnimationController.unbounded(
vsync: this,
)..value = 0;
bool _isPressed = false;
bool get _isDisabled =>
widget.isDisabled || widget.onPressed == null || widget.isLoading;
@override
void didUpdateWidget(AppButton oldWidget) {
super.didUpdateWidget(oldWidget);
// Disabled mid-press: release rather than stay stranded in the pressed
// look, since the gesture callbacks that would have released it are gone.
if (_isDisabled) _setPressed(false);
}
@override
void dispose() {
_press.dispose();
super.dispose();
}
void _setPressed(bool value) {
if (_isPressed == value) return;
_isPressed = value;
_press.animateWith(
SpringSimulation(
_AppButtonSprings.press,
_press.value,
value ? 1 : 0,
0,
),
);
}
@override
Widget build(BuildContext context) {
final AppButtonTheme theme = AppButton._theme;
final AppButtonColors colors = theme.colorsOf(context);
final AppButtonSizing sizing = theme.sizing;
final bool isDisabled = _isDisabled;
final AppButtonState state = isDisabled
? AppButtonState.disabled
: AppButtonState.defaultState;
final Color fgColor =
widget.foregroundColor ?? colors.foregroundFor(widget.type, state);
final double borderRadius = widget.borderRadius ?? sizing.borderRadius;
final EdgeInsets padding = widget.padding ?? sizing.paddingFor(widget.size);
final TextStyle textStyle = widget.textStyle == null
? sizing.textFor(widget.size, fgColor)
: widget.textStyle!.copyWith(color: fgColor);
final Color? borderColor =
widget.borderColor ?? colors.borderFor(widget.type, state);
// Solid accent fill for an unstyled, enabled primary — same idea as
// stormej.me `bg-primary` (no gradient, no glow).
final bool usesAccent =
widget.type == AppButtonType.primary &&
widget.backgroundColor == null &&
!isDisabled;
final Color? restingBg = isDisabled
? (widget.disabledBackgroundColor ??
colors.backgroundFor(widget.type, AppButtonState.disabled))
: (widget.backgroundColor ??
(usesAccent
? colors.accent
: colors.backgroundFor(widget.type, state)));
// Press: primary darkens toward accentStrong; flat types pick up surface.
final Color? pressedBg = switch (widget.type) {
AppButtonType.primary => usesAccent ? colors.accentStrong : restingBg,
AppButtonType.secondary || AppButtonType.tertiary => colors.surface,
};
final Widget content = Align(
widthFactor: widget.width == null ? 1 : null,
heightFactor: widget.height == null ? 1 : null,
child: _buildContent(fgColor, textStyle, sizing.gap),
);
return Semantics(
button: true,
enabled: !isDisabled,
label: widget.semanticLabel ?? widget.text,
child: GestureDetector(
onTap: isDisabled ? null : widget.onPressed,
onTapDown: isDisabled ? null : (_) => _setPressed(true),
onTapUp: isDisabled ? null : (_) => _setPressed(false),
onTapCancel: isDisabled ? null : () => _setPressed(false),
child: AnimatedBuilder(
animation: _press,
builder: (BuildContext context, Widget? child) {
final double t = _press.value;
// Scale rides any overshoot the spring produces. Colors clamp:
// a stop extrapolated past its endpoint is a different color,
// not a livelier one.
final double ct = t.clamp(0.0, 1.0);
return Transform.scale(
scale: 1 - theme.pressScale * t,
child: Container(
width: widget.width,
height: widget.height,
padding: padding,
decoration: ShapeDecoration(
color: Color.lerp(restingBg, pressedBg, ct),
shape: RoundedSuperellipseBorder(
borderRadius: BorderRadius.circular(borderRadius),
side: borderColor == null
? BorderSide.none
: BorderSide(color: borderColor),
),
),
child: child,
),
);
},
child: content,
),
),
);
}
Widget _buildContent(Color fgColor, TextStyle textStyle, double gap) {
// Every type but tertiary swaps its whole content for the spinner —
// tertiary is bare text, so a swap would collapse the button's width.
if (widget.isLoading && widget.type != AppButtonType.tertiary) {
return _AppButtonSpinner(color: fgColor, size: 18);
}
final bool hasText = widget.text != null && widget.text!.isNotEmpty;
final List<Widget> children = <Widget>[];
void add(Widget child) {
if (children.isNotEmpty) children.add(SizedBox(width: gap));
children.add(child);
}
if (widget.leadingIcon != null) {
add(_themedIcon(widget.leadingIcon!, fgColor));
}
if (hasText) {
add(Text(widget.text!, style: textStyle));
}
if (widget.isLoading && widget.type == AppButtonType.tertiary) {
add(_AppButtonSpinner(color: fgColor, size: 16));
} else if (widget.trailingIcon != null) {
add(_themedIcon(widget.trailingIcon!, fgColor));
}
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: children,
);
}
Widget _themedIcon(Widget icon, Color color) =>
IconTheme(data: IconThemeData(color: color), child: icon);
}
class _AppButtonSpinner extends StatelessWidget {
const _AppButtonSpinner({required this.color, required this.size});
final Color color;
final double size;
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
);
}
}it imports material and physics from the flutter sdk. nothing goes in
pubspec.yaml.
use it
there is no init call. the defaults stand on their own.
AppButton(text: "Continue", onPressed: _submit)usage
three types. one primary per screen, that's the point of it.
AppButton(text: "Continue", onPressed: _submit);
AppButton(text: "Cancel", type: AppButtonType.secondary, onPressed: _close);
AppButton(text: "Skip", type: AppButtonType.tertiary, onPressed: _skip);three sizes. lg is the default, since most buttons in an app sit at the bottom
of a screen.
AppButton(text: "Save", size: AppButtonSize.sm, onPressed: _save);
AppButton(text: "Save", size: AppButtonSize.md, onPressed: _save);
AppButton(text: "Save", size: AppButtonSize.lg, onPressed: _save);loading swaps the content for a spinner and blocks taps. disabled does the same without the spinner.
AppButton(text: "Saving", isLoading: true, onPressed: _save);
AppButton(text: "Save", isDisabled: true, onPressed: _save);
AppButton(text: "Save", onPressed: null); // also disabledicons on either side. text is optional if you pass one.
AppButton(
text: "Continue",
trailingIcon: const Icon(Icons.arrow_forward, size: 18),
onPressed: _next,
);
AppButton(
leadingIcon: const Icon(Icons.close, size: 18),
semanticLabel: "Close",
type: AppButtonType.tertiary,
onPressed: _close,
);stretch it across the screen.
AppButton(text: "Continue", width: double.infinity, onPressed: _submit);example
class CheckoutView extends StatefulWidget {
const CheckoutView({super.key});
@override
State<CheckoutView> createState() => _CheckoutViewState();
}
class _CheckoutViewState extends State<CheckoutView> {
bool _isPaying = false;
Future<void> _pay() async {
setState(() => _isPaying = true);
await context.read<Cart>().checkout();
if (mounted) setState(() => _isPaying = false);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: const CartList(),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
AppButton(
text: "Pay now",
width: double.infinity,
isLoading: _isPaying,
onPressed: _pay,
),
const SizedBox(height: 8),
AppButton(
text: "Keep shopping",
type: AppButtonType.tertiary,
size: AppButtonSize.md,
onPressed: () => Navigator.pop(context),
),
],
),
),
),
);
}
}reference
everything below is here when you need it. you can ship without reading any of it.