flutter_empty_state 0.1.1
flutter_empty_state: ^0.1.1 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. - ð§Đ 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.






