flutter_empty_state 0.3.0
flutter_empty_state: ^0.3.0 copied to clipboard
Lightweight, theme-aware empty, error, no-internet, search-empty and loading state widgets for Flutter. Customizable and dependency-free.
flutter_empty_state #
Stop rewriting the same Center(child: Text('No data found')) in every screen.
flutter_empty_state is a tiny, dependency-free set of widgets for the states every app has but nobody enjoys building: empty, error, offline, empty search, and loading. They use your app's theme out of the box, look right in light and dark, and stay fully customisable when you need them to.
return items.isEmpty
? const EmptyState(title: 'No products yet')
: ProductList(items: items);
Preview #
| Empty | Error | No internet |
|---|---|---|
![]() |
![]() |
![]() |
| Search empty | Skeleton loader | Dark mode |
![]() |
![]() |
![]() |
Features #
- Zero dependencies — pure Flutter, nothing extra to audit or update.
- Theme-aware — colours and text styles come from your
ThemeData, so light/dark just works. - Six ready-made states —
EmptyState,ErrorState,NoInternetState,SearchEmptyState,LoadingState,SuccessState. StateView— render the right widget from a singleViewStatevalue, with a cross-fade between states.- Future & Stream views —
FutureStateView/StreamStateViewmap an async source straight to loading / error / empty / content. - Localized — every default string is translatable; 10 languages ship in the box, with English fallback.
- Pull-to-refresh — pass
onRefreshand any state becomes aRefreshIndicatorscrollable. - Async-aware buttons — return a
Futurefrom an action and the button shows inline progress until it's done. - Error details — tuck raw exception text behind a collapsible, selectable "Details" disclosure.
- Global styling — set your defaults once with an
EmptyStateTheme, override per widget when needed. - Subtle entrance animation — a tasteful fade + slide that respects the OS "reduce motion" setting.
- Skeleton loaders — shimmer list, paragraph, card and grid primitives, RTL-aware and themable.
- Accessible — real text widgets, decorative icons, and a live region on the loading state.
- Drops in anywhere —
Scaffold,Center,Column,ListViewandCustomScrollViewall work without layout gymnastics. - Null-safe and covered by widget tests.
Installation #
Add it to your pubspec.yaml:
dependencies:
flutter_empty_state: ^0.1.0
Then run flutter pub get and import it:
import 'package:flutter_empty_state/flutter_empty_state.dart';
Basic usage #
Every widget works on its own with zero required parameters, so you can start small and customise later:
const EmptyState(); // sensible defaults
const LoadingState(); // a centered spinner
The widgets #
EmptyState #
EmptyState(
icon: Icons.inbox_outlined,
title: 'No data found',
message: 'There is nothing to show here yet.',
actionText: 'Refresh',
onAction: () => _reload(),
)
The action button shows up only when you pass both
actionTextandonAction.
Need a second, lower-emphasis option? Add a secondary action and it renders as
a TextButton under the main one:
EmptyState(
title: 'Your cart is empty',
actionText: 'Browse products',
onAction: _browse,
secondaryActionText: 'View wishlist',
onSecondaryAction: _openWishlist,
)
Async actions & pull-to-refresh #
Every action callback accepts a Future. Return one and the button disables
itself and shows a small inline spinner until the work finishes — no state
management needed:
ErrorState(
onAction: () async => _reload(), // button shows progress while this runs
)
And if you'd rather let users pull down to reload (even though there's no list
on screen), pass onRefresh and the state wraps itself in a RefreshIndicator:
EmptyState(
title: 'No orders yet',
onRefresh: () => _reload(),
)
ErrorState #
ErrorState(
title: 'Something went wrong',
message: 'Please try again later.',
actionText: 'Retry',
onAction: () => _retry(),
)
title, message and actionText already default to error-friendly copy, so ErrorState(onAction: _retry) is enough for a working retry screen.
Got a raw exception you'd like to keep around for bug reports? Pass it as
details and it hides behind a collapsed, selectable "Details" section:
ErrorState(
onAction: _retry,
details: error.toString(), // expands on tap, selectable for copy-paste
)
NoInternetState #
NoInternetState(
onRetry: () => _reconnect(),
)
SearchEmptyState #
SearchEmptyState(
query: 'iPhone',
onClear: () => _clearSearch(),
)
When you don't pass a message, it builds one from the query — e.g. No matches for "iPhone".
LoadingState #
LoadingState(
message: 'Loading...',
)
SuccessState #
For the happy path — order placed, file uploaded, form submitted:
SuccessState(
title: 'Order placed',
message: 'We\'ll email you a receipt shortly.',
actionText: 'Back to home',
onAction: _goHome,
)
Same layout as the other states, tuned for good news: the check icon defaults to your theme's primary colour instead of the muted grey.
Skeleton loading #
For a more polished wait, swap the spinner for a shimmering placeholder list:
StateView(
state: state,
loading: const SkeletonList(), // shimmer instead of a spinner
child: ProductList(items: items),
)
Need a custom shape? Compose your own from the Skeleton, SkeletonParagraph and Shimmer primitives:
Shimmer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Skeleton.circle(size: 56),
SizedBox(height: 12),
SkeletonParagraph(lines: 3), // a text block with a shorter last line
SizedBox(height: 8),
Skeleton(width: 180, height: 14),
],
),
)
Building a grid instead of a list? SkeletonGrid (made of SkeletonCards) is the grid counterpart to SkeletonList:
StateView(
state: state,
loading: const SkeletonGrid(crossAxisCount: 2),
child: ProductGrid(items: items),
)
The shimmer is theme-aware (looks right in light and dark), follows the text direction (RTL included) and freezes to a static placeholder when "reduce motion" is on. Want brand-specific colours? Set skeletonBaseColor / skeletonHighlightColor on your EmptyStateTheme.
StateView #
Describe each state once and switch on a single value instead of juggling if/else chains in your build method:
StateView(
state: viewState, // a ViewState
loading: const LoadingState(),
empty: const EmptyState(title: 'No products found'),
error: ErrorState(onAction: _retry),
noInternet: NoInternetState(onRetry: _retry),
child: ProductList(items: items),
)
In the common case you don't even need to spell out the error states — pass
onRetry once and the default error and no-internet widgets come pre-wired
with a working retry button:
StateView(
state: viewState,
onRetry: _load,
child: ProductList(items: items),
)
Want something fancier than the default cross-fade? transitionBuilder has
the same contract as AnimatedSwitcher:
StateView(
state: viewState,
transitionBuilder: (child, animation) =>
ScaleTransition(scale: animation, child: child),
child: ProductList(items: items),
)
ViewState is just an enum:
enum ViewState { loading, empty, error, noInternet, content }
Any slot you leave out falls back to a default widget, so you only fill in what you want to change. A typical controller flow looks like this:
ViewState state = ViewState.loading;
Future<void> load() async {
setState(() => state = ViewState.loading);
try {
items = await api.fetchProducts();
setState(() => state = items.isEmpty ? ViewState.empty : ViewState.content);
} on SocketException {
setState(() => state = ViewState.noInternet);
} catch (_) {
setState(() => state = ViewState.error);
}
}
Driving from a Future or Stream #
Most of the time you don't even track a ViewState yourself — you have a
Future or a Stream. FutureStateView and StreamStateView wire the whole
lifecycle (loading → error / offline / empty → content) to it for you, so the
controller flow above collapses to a single widget:
FutureStateView<List<Product>>(
future: _future,
isEmpty: (products) => products.isEmpty, // → empty state
noInternetWhen: (e) => e is SocketException, // → no-internet state
onRetry: () => setState(() => _future = _load()), // pre-wired retry button
builder: (context, products) => ProductList(products: products),
)
StreamStateView has the same API for a Stream. Already inside a
FutureBuilder/StreamBuilder? Use AsyncStateView, which takes the
AsyncSnapshot directly. All three leave any slot you don't set to the same
sensible defaults as StateView.
Localization #
Every default string ("Retry", "No internet connection", …) is translatable. Ten languages ship in the box — English, Uzbek, Russian, Spanish, French, German, Portuguese, Turkish, Arabic and Chinese — and any other locale falls back to English. It works with zero setup; to switch languages, register the delegate:
MaterialApp(
localizationsDelegates: const [
EmptyStateLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
],
supportedLocales: EmptyStateLocalizations.supportedLocales,
)
Explicit strings always win, so ErrorState(title: 'Custom') overrides the
translation, and title: null still hides the element. Need a language that
isn't bundled? Subclass EmptyStateLocalizations and provide it through your
own delegate.
Custom styling #
Nothing is locked down. Override as little or as much as you like:
EmptyState(
icon: Icons.favorite_border,
iconSize: 96,
iconColor: Colors.pink,
title: 'No favourites yet',
message: 'Tap the heart on any item to save it here.',
spacing: 20,
padding: const EdgeInsets.all(32),
textAlign: TextAlign.center,
titleStyle: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
messageStyle: TextStyle(color: Colors.grey.shade600),
actionText: 'Browse items',
onAction: _browse,
buttonStyle: FilledButton.styleFrom(backgroundColor: Colors.pink),
)
Need a full illustration instead of a Material icon? Use iconWidget:
EmptyState(
iconWidget: Image.asset('assets/empty_box.png', height: 140),
title: 'Your cart is empty',
)
Global styling with EmptyStateTheme #
Don't want to repeat the same iconColor: / spacing: on every screen? Set your defaults once as a theme extension and every widget picks them up:
MaterialApp(
theme: ThemeData(
extensions: const [
EmptyStateTheme(
iconColor: Colors.teal,
spacing: 20,
titleStyle: TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
),
],
),
)
Resolution order for every property is explicit argument → EmptyStateTheme → built-in default, so a one-off EmptyState(iconColor: Colors.red) still wins where you need it.
Animation & accessibility #
Each widget plays a subtle fade-and-slide when it first appears, and StateView cross-fades between states. It's all opt-out:
EmptyState(animate: false); // no entrance animation
EmptyState(animationDuration: const Duration(milliseconds: 200));
StateView(state: state, duration: Duration.zero, child: ...); // no cross-fade
Animations are skipped automatically when the user has "reduce motion" turned on. The loading state is also exposed as a live region with a semantic label, so screen readers announce the wait.
Light & dark themes #
By default every widget reads its colours and text styles from Theme.of(context):
- the icon uses
colorScheme.onSurfaceVariant, - the title uses
textTheme.titleMedium, - the message uses
textTheme.bodyMedium, - the button is a Material 3
FilledButtonthat follows yourcolorScheme.
So switching your app between light and dark needs no extra work here — these widgets follow along.
Placing it in a layout #
The widgets center themselves when they're given a bounded height (a Scaffold body, a Center, an Expanded) and lay out naturally inside a scrollable like ListView, so the same widget works in all of these:
Scaffold(body: const EmptyState()); // centered on screen
Center(child: const EmptyState()); // centered
Column(children: [Expanded(child: EmptyState())]); // fills and centers
ListView(children: const [EmptyState()]); // sits naturally, no crash
Inside a CustomScrollView, wrap the state in a SliverFillRemaining and it
centers in the leftover space under your app bar:
CustomScrollView(
slivers: [
const SliverAppBar(title: Text('Orders')),
SliverFillRemaining(
hasScrollBody: false,
child: EmptyState(title: 'No orders yet'),
),
],
)
Why use this package? #
- Consistency — the same empty/error/loading look across every screen and every project.
- Less boilerplate — no more hand-rolled
Center/Column/Textfor each state. - No baggage — zero runtime dependencies and a small surface area.
- Theme-first — light and dark are handled for you.
- Customisable when it matters — good defaults now, full control later.
Running the tests #
flutter test # unit + widget tests
flutter test --exclude-tags golden # what CI runs (skips pixel tests)
flutter test --update-goldens --tags golden # regenerate the golden images
Golden (pixel) tests are tagged golden and excluded on CI, since golden files
are tied to the platform that generated them.
Contributing #
Issues and pull requests are welcome over on GitHub. CI runs formatting, analysis, tests and a publish dry-run on every push.






