A blocking-async overlay that never flickers. Wrap any Future in one call: delayed reveal, minimum display time, reference counting, cancellation, and themed presets.
A blocking-async overlay that never flickers.
Wrap a future in one call. loading_kit decides whether an overlay is even
warranted, holds it long enough to read, counts overlapping requests, settles
into a check or a cross, and cleans up after itself when the route changes.
final user = await Loading.run( () => api.signIn(email, password), message: 'Signing in…', successMessage: 'Welcome back',);

Left: a bool and a Stack. Right: loading_kit. Same requests, fired at the same moment.
Most loading overlays are a spinner in a Stack with a bool. They work until
the network is fast, and then they strobe: a request returns in 90ms, the
spinner appears and vanishes inside three frames, and the screen flinches.
Two rules fix that, and they are the reason this package exists.
Nothing paints before the reveal delay. An operation that resolves in under
140ms renders nothing at all — not a spinner, not a success tick. Fast paths
stay visually silent. In the GIF above the left panel gets a single frame of
spinner; the right panel never moves.
Once painted, it holds. An overlay that appeared at 140ms and tore down at
170ms reads as a glitch, so it stays for at least half a second. The wait is
deliberate, and it looks deliberate.
Everything else in the package follows from taking blocking state seriously
rather than from having more spinner shapes.
dependencies: loading_kit: ^0.1.0
One line in your app, plus an observer so overlays cannot outlive their screen.
MaterialApp( builder: LoadingKit.builder(style: LoadingStyle.glass), navigatorObservers: [LoadingNavigatorObserver()], home: const HomePage(),);
The host sits above the navigator, so the overlay covers every route — dialogs
and bottom sheets included — and survives transitions underneath it.
final orders = await Loading.run(() => repo.fetchOrders());
run rethrows whatever the task threw, so your error handling is unchanged.
Its future completes only once the overlay has finished leaving — returning
earlier would let you navigate out from under a still-animating overlay, which
is the flicker this package exists to prevent. Pass awaitFeedback: false to
opt out.
await Loading.runTask((task) async { for (var i = 0; i < files.length; i++) { task.throwIfCancelled(); task.report((i + 1) / files.length, detail: '${i + 1} of ${files.length}'); await upload(files[i]); }}, message: 'Uploading…', cancelAfter: const Duration(seconds: 3));cancelAfter reveals the cancel affordance only once that much time has
passed, so quick operations never offer one. Cancellation is cooperative:
tapping cancel rejects the future with LoadingCancelled immediately, and the
body stops at its next throwIfCancelled().
final upload = Loading.show(message: 'Preparing…');upload.update(message: 'Compressing…', progress: 0.2);upload.progress = 0.85;await upload.success('Done');await Loading.run( () => api.slowCall(), timeout: const Duration(seconds: 20), errorMessage: 'Timed out',);
LoadingIndicator needs no overlay and no controller.
const LoadingIndicator(size: 48)LoadingIndicator(progress: 0.6)LoadingIndicator(status: LoadingStatus.success)LoadingIndicator(indicatorStyle: LoadingIndicatorStyle.ripple)
Not everything deserves a scrim. A toast reports an outcome without blocking
anything, and dismisses itself:
Loading.toast('Draft saved');Loading.toastSuccess('Order placed');Loading.toastError('Could not sync', detail: 'Retrying in the background');Toasts never intercept input, stack up to three at a time, and reuse the
resolved theme so they match the overlay.
Blacking out the whole app for a form that saves in place is heavy-handed.
LoadingBarrier scopes the overlay to a subtree — and still applies the
timing policy, so a fast save flashes nothing:
LoadingBarrier( loading: _saving, message: 'Saving…', borderRadius: BorderRadius.circular(16), child: const ProfileForm(),)
For long operations a bar is easier to read at a glance — the difference
between 60% and 70% is obvious in a line and subtle in a circle:
LoadingStyle.material.copyWith(progressStyle: LoadingProgressStyle.bar)
The outcome still arrives as the glyph, so a finished bar cross-fades to a
check or a cross.
adaptive (the default) resolves to cupertino on Apple platforms and
material elsewhere. Every preset resolves against the ambient ThemeData, so
light and dark both work with no configuration.
| Preset | Look |
| --- | --- |
| cupertino | Compact, low-contrast card in the iOS idiom |
| material | Tonal Material 3 surface using your primary colour |
| glass | Frosted translucent panel with a luminous edge |
| minimal | Indicator only on a soft scrim — cheapest to paint |
| neon | Dark panel with a saturated, glowing indicator |

Six indeterminate forms. Every one settles into the same check or cross, so
the outcome reads identically no matter which spinner preceded it.

LoadingStyle.material.copyWith(indicatorStyle: LoadingIndicatorStyle.bars)
arc · dots · bars · orbit · pulse · ripple
Determinate work always draws as an arc or a bar regardless of this setting —
no pulsing or bouncing form can express "62%".
The built-in shapes are optional. indicatorBuilder hands the whole slot to a
widget of yours — a Lottie file, a Rive animation, your brand mark, or anything
from another spinner package:
LoadingKit.builder( style: LoadingStyle.material.copyWith( indicatorBuilder: (context, spec) => SpinKitCubeGrid( color: spec.statusColor, size: spec.size, ), ),)
spec carries the resolved status, progress, size, colours and stroke width,
so a custom indicator still tracks your theme.
Override any token without leaving the preset:
LoadingKit.builder( style: LoadingStyle.cupertino.copyWith( indicatorColor: brand.teal, cardRadius: BorderRadius.circular(20), scrimBlur: 12, ),)
Tune the timing the same way:
LoadingKit.builder( timing: const LoadingTiming( delay: Duration(milliseconds: 180), minVisible: Duration(milliseconds: 600), ),)
LoadingTiming.instant disables both rules, and LoadingTiming.relaxed waits
longer before committing for operations you expect to be slow.
LoadingTiming decides when the overlay appears and leaves. LoadingMotion
decides how fast the thing on screen moves once it is there:
LoadingStyle.material.copyWith(motion: LoadingMotion.calm)LoadingStyle.material.copyWith( motion: const LoadingMotion(spinPeriod: Duration(seconds: 2)),)
standard, brisk and calm are built in. Changing motion is purely
cosmetic — it cannot affect the anti-flicker guarantees, which live in
LoadingTiming.
Every constant is a token, and all of them layer on top of any preset:
| Group | Tokens |
| --- | --- |
| Scrim | scrimColor, backdropBlur |
| Card | showCard, cardColor, cardBorderColor, cardBorderWidth, cardRadius, cardShadow, cardPadding, cardMinWidth, maxCardWidth |
| Indicator | indicatorStyle, indicatorSize, indicatorStroke, indicatorColor, trackColor, successColor, errorColor, indicatorGlow, indicatorBuilder |
| Progress | progressStyle |
| Text | messageStyle, detailStyle, cancelStyle, textGap |
| Cancel | cancelMinimumSize, cancelPadding |
| Layout | spacing, alignment |
| Transition | enterCurve, exitCurve, enterScale |
| Motion | motion — 5 durations |
| Toasts | toast — 7 metrics, plus toastExitDuration, defaultToastDuration and maxVisibleToasts on the controller |
bool does notthe last one retires, so an early return cannot strand another request.
running, the spinner stays. No check mark flashes mid-flight.
LoadingNavigatorObserver clears overlays when theroute beneath them changes, so a spinner cannot get stuck over a screen that
never asked for it.
behind it.
hardware keyboard cannot reach buttons under the scrim.
and its progress, and BlockSemantics hides the blocked app underneath.
fewer animations.
CustomPainter. The arc closes into a ring, crosses to the terminal colour,
and strokes the glyph on inside it — rather than swapping one widget for an
unrelated one.

SizedBox.shrink(): no scrim, no blur, no ticker, no hit-test target.
ValueListenable andrebuilds one small subtree. The app subtree is passed through by identity, so
Flutter skips it entirely when loading starts or stops.
repeating ticker rather than leaving it running through the exit animation.
glass and neon presets blur, andthe filter is clipped to the card rather than compositing the whole screen.
saveLayer for the indicator.
Everything is timer-driven, so timing behaviour is testable on the fake clock
with no real waiting:
testWidgets('a fast call shows nothing', (tester) async { final controller = LoadingController(); addTearDown(controller.dispose); final work = controller.run(() => Future.delayed(const Duration(milliseconds: 80))); await tester.pump(const Duration(milliseconds: 80)); expect(controller.value.visible, isFalse); await tester.pump(const Duration(milliseconds: 400)); await work;});One caveat that applies to any indeterminate indicator, Flutter's own included:
a spinning arc schedules a frame forever, so pumpAndSettle() will not settle
while one is on screen. Pump explicit durations instead.
The global Loading facade is a convenience for code with no BuildContext.
Where a context is available, prefer the scoped controller — it is ordinary
state rather than shared state, and trivially testable:
await context.loading.run(() => repo.save(draft));
You can also host an overlay over part of the app rather than all of it:
LoadingHost( registerGlobal: false, controller: myController, child: const EditorPane(),)
MIT © K M Shahriar Hossain
The GIFs are produced by driving the package through Flutter's own rasterizer
on a fake clock, so they show the real timing frame-accurately rather than
whatever a screen recorder happened to catch.
flutter test tool/record_frames.dart # writes doc/frames/<scene>/*.png
./tool/build_gifs.sh # writes doc/<scene>.gif