flutter_empty_state 0.3.0 copy "flutter_empty_state: ^0.3.0" to clipboard
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 #

pub package CI codecov license: MIT

▶ Live demo · pub.dev

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 statesEmptyState, ErrorState, NoInternetState, SearchEmptyState, LoadingState, SuccessState.
  • StateView — render the right widget from a single ViewState value, with a cross-fade between states.
  • Future & Stream viewsFutureStateView / StreamStateView map 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 onRefresh and any state becomes a RefreshIndicator scrollable.
  • Async-aware buttons — return a Future from 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 anywhereScaffold, Center, Column, ListView and CustomScrollView all 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 actionText and onAction.

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 FilledButton that follows your colorScheme.

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/Text for 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 #

MIT

1
likes
160
points
58
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Lightweight, theme-aware empty, error, no-internet, search-empty and loading state widgets for Flutter. Customizable and dependency-free.

Repository (GitHub)
View/report issues

Topics

#ui #widget #empty-state #placeholder #loading

License

MIT (license)

Dependencies

flutter

More

Packages that depend on flutter_empty_state