loadable_buttons 2.0.0
loadable_buttons: ^2.0.0 copied to clipboard
Provides enhanced buttons with built-in loading states, async functionality and customizable transitions.
loadable_buttons #
Flutter Material and Cupertino buttons with automatic loading states, external loading control, and customizable indicators and transitions.

Installation #
flutter pub add loadable_buttons
flutter pub add material_ui
# For Cupertino applications:
flutter pub add cupertino_ui
Version 2 requires Flutter 3.44.0+, Dart 3.12.0+, and standalone material_ui 1.0.0+ and cupertino_ui 1.0.0+. Pub selects compatible design-library releases for your SDK; Material UI 1.4.0 and newer require Flutter 3.47 / Dart 3.13. Cupertino UI 1.1.1 also requires Flutter 3.47 / Dart 3.13. Version 1.x remains the compatibility line for Flutter 3.29 and the built-in Material library. 1.1.x receives applicable compatibility fixes without adopting v2 APIs or raising its SDK floor in patch releases. See the compatibility roadmap.
Quick start #
Paste this example into lib/main.dart in a Flutter application:
import 'package:loadable_buttons/loadable_buttons.dart';
import 'package:material_ui/material_ui.dart';
void main() {
runApp(
MaterialApp(
theme: ThemeData(useMaterial3: true),
home: Scaffold(
body: Center(
child: AsyncElevatedButton(
onPressed: saveChanges,
loadingSemanticsLabel: 'Saving',
child: const Text('Save'),
),
),
),
),
);
}
Future<void> saveChanges() async {
// Replace this delay with your application's async operation.
await Future<void>.delayed(const Duration(seconds: 2));
}
The button shows a spinner and prevents repeated activation until the returned
Future completes. The following examples reuse saveChanges.
Cupertino quick start #
Use package:loadable_buttons/cupertino.dart with the standalone Cupertino
library. material.dart is available for Material-only imports, and the original
loadable_buttons.dart exports both families. Design-specific entry points export
only their buttons plus AsyncButtonErrorHandler and TransitionAnimationType.
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:loadable_buttons/cupertino.dart';
void main() => runApp(
CupertinoApp(
home: CupertinoPageScaffold(
child: Center(
child: AsyncCupertinoButton.filled(
onPressed: saveChanges,
loadingSemanticsLabel: 'Saving changes',
child: const Text('Save'),
),
),
),
),
);
Future<void> saveChanges() async {
await Future<void>.delayed(const Duration(seconds: 2));
}
The default, filled, and tinted constructors preserve native Cupertino sizes,
padding, colors, press feedback, focus, and disabled behavior. They use
CupertinoActivityIndicator sized to the button text, with the theme's primary
color or your explicit foregroundColor. Loading disables the native button, so
filled and tinted buttons show disabledColor; keep an explicit foreground
readable on it. No Material ancestor is needed. The same loading, error,
transition, and accessibility contracts below apply. Use minimumSize; the
deprecated native minSize is omitted.
Run the Cupertino example with
cd example && flutter run -t lib/cupertino_main.dart for all three variants,
external loading, long presses, custom loading content, and handled errors.
Migrating from 1.x #
Follow the complete migration guide for release-line selection and before/after examples covering plain/icon buttons, loading labels, error hooks, custom transitions, and Cupertino.
Add material_ui as a direct dependency and replace
package:flutter/material.dart imports with package:material_ui/material_ui.dart.
Create MaterialApp, themes, styles, and ink factories from that package too.
Material types such as ButtonStyle, ThemeData, IconAlignment, and
InteractiveInkFeatureFactory are distinct from their legacy Flutter types;
legacy values cannot be passed to the v2 constructors. Widget state types
continue to come from Flutter's widgets library.
If your app uses Material localizations, use the standalone
GlobalMaterialLocalizations.delegates. Applications with dependencies that
still use Flutter's built-in Material widgets can wrap those subtrees in
MaterialUiCompatibilityBridge under a standalone MaterialApp:
MaterialApp(
builder: (context, child) => MaterialUiCompatibilityBridge(
child: child ?? const SizedBox.shrink(),
),
home: const HomeScreen(), // Your application's screen.
);
The bridge is a temporary, deprecated migration utility. It supplies legacy themes and localizations; it does not convert legacy style arguments to standalone types. See the official Material UI migration guide.
Button families #
| Widget | Constructors |
|---|---|
AsyncCupertinoButton |
Default, .filled, .tinted |
AsyncElevatedButton |
Default, .icon |
AsyncFilledButton |
Default, .icon, .tonal, .tonalIcon |
AsyncOutlinedButton |
Default, .icon |
AsyncTextButton |
Default, .icon |
AsyncIconButton |
Default, .filled, .filledTonal, .outlined |
AsyncFloatingActionButton |
Default, .small, .large, .extended |
Use native Material options such as style, or Cupertino options such as
sizeStyle, minimumSize, padding, and foregroundColor. Both support
focusNode.
For each widget's supported properties, see the
API reference.
For a button with an icon, use label instead of child:
AsyncFilledButton.icon(
onPressed: saveChanges,
icon: const Icon(Icons.save_outlined),
label: const Text('Save changes'),
);
Material style shortcuts #
AsyncElevatedButton, AsyncFilledButton, AsyncOutlinedButton, and
AsyncTextButton accept these optional shortcuts, including their icon, tonal,
and null-icon variants:
| Parameter | Native mapping |
|---|---|
padding |
ButtonStyle.padding |
minimumSize |
ButtonStyle.minimumSize; native density, constraints, and tap targets still apply |
alignment |
ButtonStyle.alignment |
backgroundColor, foregroundColor |
Enabled-state colors |
disabledBackgroundColor, disabledForegroundColor |
Disabled-state colors; loading disables the outer button |
mouseCursor |
ButtonStyle.mouseCursor; plain or state-dependent MouseCursor |
All default to null. For each property and state, precedence is non-null
shortcut → supplied style → family theme → native defaults. A cursor that
resolves to null still checks that state's supplied style before the theme.
Enabled color shortcuts preserve disabled colors; disabled shortcuts preserve
enabled colors. Unrelated style properties and native interaction feedback are
retained, including Material's separate iconColor precedence.
AsyncFilledButton.icon(
onPressed: saveChanges,
padding: const EdgeInsetsDirectional.fromSTEB(20, 12, 24, 12),
minimumSize: const Size(160, 48),
disabledBackgroundColor: Colors.blueGrey,
style: FilledButton.styleFrom(shape: const StadiumBorder()),
icon: const Icon(Icons.save_outlined),
label: const Text('Save changes'),
);
Here only the disabled/loading background changes; enabled colors still come
from the style, Filled theme, or native defaults. foregroundColor also colors
the default loading spinner using its enabled value. minimumSize is a lower
bound, so larger content can still grow the button. Use style for other native
options. IconButton and FAB retain their own native styling and sizing APIs.
Loading behavior #
onPressed accepts synchronous or asynchronous callbacks. Return or await your
async work so the button can track its completion.
External loading and a pending callback are independent: the button stays
loading while either is active, including any pending onError handler.
// isSaving is a bool managed by your application.
AsyncElevatedButton(
loading: isSaving,
onPressed: saveChanges,
child: const Text('Save'),
);
Setting loading: false does not cancel or unlock a pending callback. Likewise,
finishing the callback does not clear external loading: true.
Callback errors #
All constructors accept an optional onError callback using the exported
AsyncButtonErrorHandler typedef:
FutureOr<void> Function(Object error, StackTrace stackTrace).
Without onError, a synchronous throw or failed callback Future propagates
with its original stack trace. Native button activation accepts a synchronous
callback, so unhandled errors reach the caller's zone through the package's
handler Future.
Supplying onError explicitly consumes the callback error when the handler
completes successfully. The package does not also log or report it. Choose your
application's feedback or reporting policy:
AsyncElevatedButton(
onPressed: saveChanges,
onError: (error, stackTrace) {
debugPrint('Saving failed: $error\n$stackTrace');
},
child: const Text('Save'),
);
The handler receives the original error and stack trace exactly once. It may be
async; the button stays loading and locked until it finishes. Internal loading
then clears, including when the handler itself throws or returns a failed Future.
A handler failure propagates with its own stack trace and does not call onError
again. External loading remains independent throughout. This hook handles only
onPressed errors; synchronous onLongPress callbacks and presentation builders
retain their normal behavior.
Each activation captures its callback and handler before starting; rebuilding
with a different handler affects the next activation. Disposing the widget does
not cancel the operation or suppress the captured handler. No BuildContext is
supplied. Check your own mounted before accessing captured state or context,
including in onError.
Launching work without returning or awaiting its Future ends the tracked callback early. The button cannot track that work or handle its later errors. See the shared callback error policy across both design families and future controller/adaptive implementations.
Set onPressed: null to disable a button. Elevated, Filled, Outlined, Text, and
Cupertino buttons remain enabled if onLongPress is provided; loading blocks both
callbacks. onLongPress is synchronous and does not start a loading state.
IconButton forwards its long-press callback to Material UI's native IconButton;
floating action buttons have no long-press parameter. Native design-library
styling, focus, and semantics determine the disabled appearance for each family.
Enabled buttons retain native keyboard activation. Loading disables the outer
button's activation and reports it as disabled to accessibility services. For
built-in transitions, inactive idle content and outgoing switcher content cannot
receive pointer input, keyboard focus, or accessibility actions, even when
minimumChildOpacity makes idle content partially visible. Current loading
content may still contain an intentional action, such as Cancel.
Customization #
Replace the spinner with loadingChild and choose a transition:
AsyncElevatedButton(
onPressed: saveChanges,
loadingChild: const Text('Saving...'),
transitionType: TransitionAnimationType.animatedSwitcher,
animationDuration: const Duration(milliseconds: 250),
child: const Text('Save'),
);
TransitionAnimationType |
Behavior |
|---|---|
stack (default) |
Keeps idle content in the layout and fades loading content over it. |
animatedSwitcher |
Fades between content; outgoing content stays in the layout until its fade ends. |
customBuilder |
Uses your required customBuilder(loading, child, loadingChild). |
animationDuration defaults to 200 milliseconds (Durations.medium1 in
Material); minimumChildOpacity defaults to 0.0 for stack transitions.
Sizing and text scaling #
Stack retains the idle content's layout, so a smaller indicator normally fits
within the idle size. A larger loadingChild can expand the button within its
parent and native constraints. Stack does not guarantee a fixed size.
Material stack indicators are centered within the whole button, including
padding, even when icon-and-label variants use asymmetric padding or content
alignment.
Cupertino transitions remain inside native content padding and alignment.
Animated switcher keeps both sizes in the layout while outgoing content fades;
the shared layout animates its resize, including the shrink after that content
is removed.
Text follows the ambient text scale, which can increase button size. Icon-and-label buttons also scale their spacing. Extended FAB stack transitions retain the icon area; padding and text styling follow Material. Fixed-size FAB variants retain Material's constraints. Oversized content or narrow parents can still overflow or clip, as with native Material buttons. Use short labels, test large text and narrow layouts, and constrain both idle and loading content when a stable size is required.
Accessible loading content #
Every constructor accepts loadingSemanticsLabel for its default spinner,
including Cupertino, icon, tonal, selected IconButton, and extended FAB variants.
The default is null; the package supplies no English text. Use your application's
localized strings for both the idle action and the loading operation:
// l10n is your application's localization object for the current context.
AsyncFilledButton.icon(
onPressed: saveChanges,
loadingSemanticsLabel: l10n.savingChanges,
icon: const Icon(Icons.save_outlined),
label: Text(l10n.save),
);
The label follows the current locale when the widget rebuilds. It is exposed
only while loading, for either external loading or a pending callback.
The outer button keeps its disabled button semantics; the spinner adds no
button role or activation action.
Custom content owns its accessible labels and progress values. For example,
the following uses your application's localized strings and a progress value
between 0.0 and 1.0:
AsyncFilledButton(
onPressed: saveChanges,
loadingChild: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
value: progress,
semanticsLabel: l10n.savingChanges,
semanticsValue: l10n.percentComplete((progress * 100).round()),
),
),
child: Text(l10n.save),
);
loadingSemanticsLabel is ignored when you supply loadingChild or use a custom
builder, so custom labels are never silently duplicated. This includes builders
that receive a null loadingChild; the package supplies neither a default
spinner nor a semantics wrapper in custom-builder mode. Built-in transitions
exclude inactive content from semantics, so a hidden idle label is not a
substitute for labeling the loading content.
The package does not request live announcements. If your application needs
them, opt in separately, such as a Semantics(liveRegion: true) status message
whose text changes when an operation starts or finishes. Trigger explicit
announcements from operation state changes, never from build or animation
frames. See the shared loading semantics policy
for the contract across both design families and future adaptive constructors.
An intentional Cancel action may remain accessible inside current loading content, while the outer button stays disabled:
AsyncOutlinedButton(
onPressed: saveChanges,
loadingChild: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(semanticsLabel: l10n.savingChanges),
),
TextButton(
onPressed: cancelSave,
child: Text(l10n.cancel),
),
],
),
child: Text(l10n.save),
);
cancelSave belongs to your operation's cancellation API. The callback Future
must finish before internal loading clears, and external loading must be cleared
by its owner. A Cancel label alone does not cancel work. The
example application demonstrates this
with a controlled Future, accessible custom progress, and a Cancel action.
Custom builders #
Select customBuilder and provide the builder together. It receives effective
loading, idle content, and the supplied nullable loadingChild; the package does
not substitute its default spinner in this mode. A builder that replaces content
immediately can avoid retaining an inactive subtree:
AsyncOutlinedButton(
onPressed: saveChanges,
transitionType: TransitionAnimationType.customBuilder,
customBuilder: (loading, child, loadingChild) => loading
? loadingChild ??
const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(semanticsLabel: 'Saving changes'),
)
: child,
child: const Text('Save'),
);
If your builder retains or animates both states, it owns sizing and must isolate
inactive and outgoing content with IgnorePointer, ExcludeFocus, and
ExcludeSemantics. Opacity alone does not disable interaction. The outer button
still manages loading and its callback lock. Extended FABs can invoke the builder
separately for their icon and label; account for both slots.
IconButton selection #
With a Material 3 theme, use isSelected and selectedIcon on any
AsyncIconButton variant. Wrap a bool in WidgetStatePropertyAll:
// In a State object's build method; isFavorite is a bool field.
AsyncIconButton.filled(
tooltip: 'Toggle favorite',
isSelected: WidgetStatePropertyAll(isFavorite),
icon: const Icon(Icons.favorite_border),
selectedIcon: const Icon(Icons.favorite),
onPressed: () async {
await saveChanges();
if (!mounted) return;
setState(() => isFavorite = !isFavorite);
},
);
Your application owns the selection state. If selectedIcon is omitted,
icon is used for both states.
AI agent skill #
The package ships a loadable-buttons-usage skill that teaches coding agents
its imports, constructors, loading and error contracts, accessibility, theming,
and custom loading content. Install it from your application's root with the
skills CLI; select your agent with --agent
(for example claude, codex, copilot, cursor, or generic):
dart run skills@ get --package loadable_buttons --agent claude
The CLI copies the skill into your agent's skills directory, such as
.claude/skills/. Rerun it after upgrading loadable_buttons to update the
skill. The package itself never installs agent configuration.
FAQ #
Can I copy a button into my project?
Yes! The code is MIT licensed. Feel free to browse the button implementations, copy a button into your project, and adapt it, including for commercial use. Keep the copyright and MIT license notice with the copied code.
Include any companion part files, async_button_helpers.dart for shared loading
state and error handling, and loading_transition.dart for the enum and widgets.
Material buttons also need async_material_button_helpers.dart for style
shortcuts, native layers, and indicators; Cupertino buttons keep their indicator
and semantics adapter in async_cupertino_button.dart. Update package imports
to match your project.
For a single-file copy, inline the shared loading helper declarations, move the enum into your button file, and inline the transition bodies. This outline shows the ternary and switch structure:
child: widget.transitionType == TransitionAnimationType.customBuilder
? widget.customBuilder
?.call(isLoading, widget.child, widget.loadingChild) ??
widget.child
: switch (widget.transitionType) {
TransitionAnimationType.stack => Stack(
// Inline the guarded stack transition here.
),
TransitionAnimationType.animatedSwitcher => AnimatedSwitcher(
duration: widget.animationDuration,
// Inline the guarded switcher transition here.
),
TransitionAnimationType.customBuilder => widget.child,
},
Copy the transition bodies from loading_transition.dart, including the pointer, focus, and semantics guards for inactive and outgoing content.
Do I need a controller or state management package?
No. Return your operation's Future from onPressed and the button manages its
loading state. Use loading when your application already manages that state.
