Shimmer Loading Effects in Flutter: Skeleton Screens Done Right
Build skeleton loading placeholders in Flutter with the shimmer package: Shimmer.fromColors, custom gradients, direction and period tuning, dark theme handling, list-level performance rules, and accessibility fallbacks that do not make users wait for an animation.
Published on • October 10, 2026
AI Assistant

A spinner says “something is happening.” A skeleton says “here is roughly what you are about to see.” That difference is why skeleton screens have become the default loading treatment in modern apps: they reduce perceived wait time by giving the user a shape to anticipate instead of an empty void to stare at.
Flutter does not ship a skeleton widget, but the shimmer package has been the community standard for years — 1.6 million downloads and 5,400 likes on pub.dev — and version 4.0 modernized it. This post covers how the widget actually works, how to build skeletons that match your real layout, and the handful of performance and accessibility rules that separate a polished skeleton from an animated mess.
In this tutorial, you will learn how to:
- Install
shimmer4.0 and understand itsmaterial_uidependency - Build a skeleton that mirrors your real list item layout
- Use
Shimmer.fromColorsversus the defaultShimmerconstructor for custom gradients - Tune
direction,period,loop, andenabledfor the right feel - Handle dark theme without hardcoding greys
- Follow the performance rules that keep the shader off the jank path
- Provide a non-animated fallback for reduced-motion users
Key technologies: shimmer, ShaderMask, AnimationController, BoxDecoration, Flutter theming.
Prerequisites
- Flutter 3.x project with at least one list or detail screen that loads over the network
- Basic comfort with
StatefulWidgetandAnimationController shimmeradded to yourpubspec.yaml
Installing shimmer 4.0
Version 4.0 changed how the package imports. It now depends on Flutter’s standalone material_ui package (Flutter 3.44+):
dependencies:
shimmer: ^4.0.0
material_ui: ^1.0.1
import 'package:material_ui/material_ui.dart';
import 'package:shimmer/shimmer.dart';
If your app still imports package:flutter/material.dart — which almost every app does — you wrap those subtrees in MaterialUiCompatibilityBridge to keep them working alongside the new package. In practice, most teams will add the bridge at the root of the screens that use shimmer and migrate gradually.
If you are on an older Flutter, shimmer 3.x remains available and uses the classic flutter/material.dart import. Nothing in the rest of this post changes between the two.
The mental model: a highlight painted over a placeholder
Shimmer drives an AnimationController and paints a ShaderMaskLayer over its child using BlendMode.srcIn. The highlight rectangle is three times the size of the child so the band can travel fully across the widget.
Two consequences fall out of that implementation, and both shape how you should build skeletons:
- The child’s colors are replaced by the gradient. Transparent pixels stay transparent; everything else becomes part of the shimmer. This is why the docs warn against putting images, decorated text, or elevation in a shimmer child — the shader overwrites the colors you carefully chose.
- The child should be static. The animation is on the mask, not the child. Rebuilding the child every frame defeats the point.
So a skeleton is not a copy of your widget. It is a set of solid-colored shapes that occupy the same space your real widget will.
Building a skeleton that matches your layout
Here is a real feed item and its skeleton counterpart:
class FeedTile extends StatelessWidget {
const FeedTile({super.key, required this.item});
final FeedItem item;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(radius: 24, backgroundImage: NetworkImage(item.avatarUrl)),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.author, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(item.body, maxLines: 3, overflow: TextOverflow.ellipsis),
],
),
),
],
),
);
}
}
The skeleton mirrors the structure with solid containers:
class FeedTileSkeleton extends StatelessWidget {
const FeedTileSkeleton({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar placeholder
SizedBox(
width: 48,
height: 48,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.white, shape: BoxShape.circle),
),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name line
SizedBox(height: 14, width: 96, child: ColoredBox(color: Colors.white)),
SizedBox(height: 8),
// Body lines, last one shorter like an ellipsis
SizedBox(height: 12, width: double.infinity, child: ColoredBox(color: Colors.white)),
SizedBox(height: 6),
SizedBox(height: 12, width: double.infinity, child: ColoredBox(color: Colors.white)),
SizedBox(height: 6),
SizedBox(height: 12, width: 180, child: ColoredBox(color: Colors.white)),
],
),
),
],
),
);
}
}
Notice what is missing: no text, no images, no icons. Just geometry. The shimmer replaces the whites with the gradient, so the color you pick for the boxes is irrelevant as long as it is opaque.
The layout match matters more than anything else. If the skeleton is shorter than the real item, the list jumps when data arrives — which defeats the entire purpose. Measure your real tile and make the skeleton the same height.
Wrapping the list: one Shimmer, not many
This is the rule the package documentation calls out explicitly: wrap a list of placeholders in one Shimmer, not one Shimmer per row.
// Right: a single shader over the whole list
Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: ListView.builder(
itemCount: 8,
itemBuilder: (_, __) => const FeedTileSkeleton(),
),
);
// Wrong: 8 animation controllers and 8 shader masks
ListView.builder(
itemCount: 8,
itemBuilder: (_, __) => Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: const FeedTileSkeleton(),
),
);
The per-row version creates one AnimationController and one ShaderMaskLayer per item. On a list of twenty rows that is twenty tickers running simultaneously. The single-wrapper version runs one.
Shimmer.fromColors versus the default constructor
Shimmer.fromColors is the usual entry point — you give it two colors and it builds a linear gradient for you:
Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: placeholder,
);
The default Shimmer constructor takes a gradient directly, which is what you want for a RadialGradient, SweepGradient, or a linear gradient driven by your theme:
Shimmer(
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.surfaceContainerHighest,
Theme.of(context).colorScheme.surface,
Theme.of(context).colorScheme.surfaceContainerHighest,
],
stops: const [0.35, 0.5, 0.65],
),
child: placeholder,
);
The stops array controls how wide the bright band is. Values clustered around the middle (as above) produce a narrower, crisper highlight; evenly spread stops produce a softer wash.
Direction, speed, and looping
Four parameters control the motion:
Shimmer.fromColors(
direction: ShimmerDirection.rtl, // ltr | rtl | ttb | btt
period: const Duration(milliseconds: 1200),
loop: 0, // 0 = forever
enabled: isLoading, // false pauses the animation
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: placeholder,
);
| Parameter | Default | Notes |
|---|---|---|
direction | ltr | ttb and btt suit vertical cards and full-screen loaders |
period | 1500ms | Duration of one pass; shorter feels snappier, longer feels calmer |
loop | 0 | 0 repeats forever; a positive count stops after that many passes |
enabled | true | Set to false when loading finishes so the ticker stops |
That last one is not cosmetic. Leaving enabled: true after data arrives keeps an AnimationController ticking forever in the background. Tying it to your loading flag is a one-line fix with a real battery and frame-budget payoff.
period deserves a moment. The 1500 ms default is deliberately slow — a fast shimmer reads as an error or a glitch. For a full-screen skeleton, 1200–1800 ms feels right. For a small inline placeholder (a button label, a chip), shorter works because the eye is not tracking a large area.
Handling dark theme
Do not hardcode grey.shade300. In dark mode, a light grey skeleton on a dark background looks like a flash. Derive both colors from the theme:
final scheme = Theme.of(context).colorScheme;
Shimmer.fromColors(
baseColor: scheme.surfaceContainerHighest,
highlightColor: scheme.surface,
child: placeholder,
);
This is the same trick as the custom gradient above, and it means your skeleton follows Material 3 tonal palettes automatically if you switch themes at runtime.
For apps with a custom brand palette, compute the two colors as a lightened and unmodified version of your surface color so the highlight stays subtle in both modes.
Toggling the skeleton off
The skeleton should exist only while loading. The straightforward version:
if (isLoading)
const FeedListSkeleton()
else
FeedList(items: items);
For a nicer transition, swap them inside an AnimatedSwitcher:
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: isLoading ? const FeedListSkeleton() : FeedList(items: items),
)
Just be careful that the two children have different keys, or AnimatedSwitcher will not cross-fade them.
Also set enabled: false on the Shimmer as soon as isLoading flips, even if the widget is still mounted during the transition. That stops the ticker immediately rather than when the widget is disposed.
Accessibility: respect reduced motion
A shimmer is an infinite animation. For users with vestibular disorders, a continuously moving highlight is a real problem — and the fix is the OS-level motion setting Flutter already exposes.
final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
Shimmer.fromColors(
enabled: isLoading && !reduceMotion,
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: placeholder,
);
When animations are disabled, enabled: false freezes the shimmer on its base color — the skeleton shape remains, communicating layout, without the movement. You get the benefit of the placeholder and none of the accessibility cost.
A complete loading screen
Putting it together:
class FeedScreen extends StatefulWidget {
const FeedScreen({super.key});
@override
State<FeedScreen> createState() => _FeedScreenState();
}
class _FeedScreenState extends State<FeedScreen> {
late Future<List<FeedItem>> _future;
@override
void initState() {
super.initState();
_future = FeedRepository.load();
}
@override
Widget build(BuildContext context) {
final reduceMotion = MediaQuery.maybeOf(context)?.disableAnimations ?? false;
final scheme = Theme.of(context).colorScheme;
return FutureBuilder<List<FeedItem>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return Shimmer.fromColors(
enabled: !reduceMotion,
baseColor: scheme.surfaceContainerHighest,
highlightColor: scheme.surface,
child: ListView.builder(
itemCount: 8,
itemBuilder: (_, __) => const FeedTileSkeleton(),
),
);
}
if (snapshot.hasError) {
return ErrorView(onRetry: () => setState(() => _future = FeedRepository.load()));
}
return FeedList(items: snapshot.data!);
},
);
}
}
One Shimmer, a single ListView, theme-derived colors, and a reduced-motion fallback. That is the whole pattern.
Performance checklist
Before you ship, walk this list:
- One
Shimmerwrapper per list, not per row - Keep the skeleton child simple — solid
Containers andSizedBoxes, no images or decorated text - Set
enabled: falsewhen loading ends so the ticker stops - Make the skeleton the same height as the real item to avoid layout jumps
- Use
MediaQuery.disableAnimationsto freeze the shimmer for reduced-motion users - Avoid shimmer inside a shrink-wrapped scrollable — the animation still runs even when the placeholder is off-screen
The underlying mechanism is a ShaderMask over a static child, which is cheap when done once and expensive when done per item. Almost every shimmer performance problem traces back to one of those two facts.