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. - ð§Đ Five ready-made states â
EmptyState,ErrorState,NoInternetState,SearchEmptyState,LoadingState. - ð
StateViewâ render the right widget from a singleViewStatevalue, with a cross-fade between states. - ð 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 loader â a built-in shimmer placeholder list for a more polished loading state.
- âŋ Accessible â real text widgets, decorative icons, and a live region on the loading state.
- ðĶ Drops in anywhere â
Scaffold,Center,ColumnandListViewall 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.
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.
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...',
)
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 and Shimmer primitives:
Shimmer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Skeleton.circle(size: 56),
SizedBox(height: 12),
Skeleton(height: 18), // fills the width
SizedBox(height: 8),
Skeleton(width: 180, height: 14),
],
),
)
The shimmer is theme-aware (looks right in light and dark) and freezes to a static placeholder when "reduce motion" is on.
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),
)
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);
}
}
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
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.
License
Libraries
- flutter_empty_state
- Lightweight, theme-aware empty / error / no-internet / search-empty / loading state widgets for Flutter â plus a StateView to switch between them from a single ViewState value, and an EmptyStateTheme to style them all at once.





