skeleton_shimmer
Loading placeholders whose highlight sweeps as one band across the whole screen, instead of every card lighting up at the same instant.


Why this instead of what you already have
Instead of a hand-rolled ShaderMask. ShaderMask is a
SingleChildRenderObjectWidget that takes shaderCallback, blendMode and
child (widgets/basic.dart:425). It holds no shared state, so two of them
cannot be put on one clock, and five cards peak at the same instant instead of
one band crossing the screen.
Instead of shimmer. Its initState builds a private
AnimationController(vsync: this, duration: widget.period)
(lib/shimmer.dart:130), one clock per widget by construction. Issue #24,
"Synchronize multiple Shimmers automatically", has been open since March
2020. It also never asks about reduced motion: disableAnimations,
AccessibilityFeatures and MediaQuery appear nowhere in its source. The
default loop: 0 path calls repeat() (lib/shimmer.dart:137), and
AnimationController.repeat() hands off to a _RepeatingSimulation with no
animationBehavior check (animation_controller.dart:717), so the sweep keeps
running at full speed after the platform has asked for it to stop.
Reach for it when
- A list or grid of placeholder cards should read as one surface rather than as tiles blinking on their own schedules.
- You ship to users who turn reduce motion on and the placeholder still has to be visible while it is held still.
- Placeholders scroll through a list and the band should belong to the screen rather than travel with each row.
Skip it for a single placeholder on a screen: one shimmer has nothing to
synchronize, and shimmer is the more widely used dependency at roughly 1.4M
downloads a month.
Two shimmers on a screen are two animations. Each one owns a clock and sweeps
its highlight across its own box. Five cards give you five highlights peaking
at the same instant rather than one band crossing the screen. ShimmerScope
puts every shimmer under it on one clock and one band, and changes nothing
else.
ShimmerScope(
child: Column(
children: [
for (final card in cards)
Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: card,
),
],
),
)

Each row is one frame of the same 1500 ms sweep. tool/build_media.sh
regenerates the figure, and the run behind it asserts the difference before it
writes the file: on the left the three tiles have to be equally lit, on the
right the two outer ones have to be well off the band.
Install
$ flutter pub add skeleton_shimmer
The widget on its own
Shimmer wraps anything and sweeps a gradient over its opaque pixels.
SkeletonBox, SkeletonCircle and SkeletonLine are the shapes you put
under it.
Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonCircle(size: 48),
SizedBox(height: 12),
SkeletonLine(width: 220),
SizedBox(height: 8),
SkeletonLine(width: 160),
SizedBox(height: 16),
SkeletonBox(height: 120),
],
),
)
What a scope does and does not take over
The scope owns the clock: period and loop belong to it. Everything else
stays on each widget: its colors, its direction, its enabled flag, its
semanticsLabel. Turning one card off holds it on the band it was showing
while its neighbours keep going.
ShimmerScope(
period: const Duration(milliseconds: 1200),
child: ListView.builder(
itemBuilder: (context, index) => Shimmer.fromColors(
baseColor: base,
highlightColor: highlight,
enabled: index != pinnedRow,
child: SkeletonRow(index),
),
),
)
A row scrolling through a scope moves through the band rather than carrying a copy of it along, because the offset is resolved during paint rather than at build time.
Migrating from shimmer
The widget API is the same; change the import and the class works as before:
// import 'package:shimmer/shimmer.dart';
import 'package:skeleton_shimmer/skeleton_shimmer.dart';
Shimmer, Shimmer.fromColors, ShimmerDirection (ltr, rtl, ttb,
btt), period, loop, and enabled all behave the way you expect.
Adding ShimmerScope is opt-in, and staying out of it costs nothing: with no
scope above it a Shimmer renders the pixels it rendered before scopes
existed. test/shimmer_scope_test.dart keeps a transcription of the 1.0.1
implementation and renders the two side by side on one clock, comparing every
pixel at eight points of a sweep in all four directions.
Reduced motion

Each row is one frame. The left card sits under the ambient MediaQuery and
the right one under disableAnimations: true: the sweep stops, the gradient
mask stays, and the placeholders still preview the layout that is coming.
Your app wires nothing up for this. MediaQueryData.fromView fills the flag in
from AccessibilityFeatures.disableAnimations and Shimmer reads it. Two
things are worth knowing anyway:
- Ignoring the flag is worse than it sounds. While it is set,
AnimationController.forward()runs its duration at 5% by default (AnimationBehavior.normal), so a shimmer that kept sweeping would restart every 75 ms instead of every 1500 ms. Measured on Flutter 3.41. - Override
MediaQuerywithcopyWith. A freshMediaQueryData(...)anywhere above aShimmerresetsdisableAnimationsto false, and then the sweep runs for someone who asked it not to.
example/lib/main.dart has a switch that turns the flag on for the feed below
it. Both states are then visible on a machine that has the setting turned off.
Screen readers
The placeholder shapes stay out of the semantics tree. A skeleton is decoration
standing in for content that has not arrived, and a run of empty containers is
nothing but an obstacle to walk past. Pass semanticsLabel to announce the
loading state instead:
Shimmer.fromColors(
baseColor: base,
highlightColor: highlight,
semanticsLabel: AppLocalizations.of(context).loading,
child: const SkeletonLine(width: 200),
)
It works like CircularProgressIndicator.semanticsLabel, including having no
default: the package cannot invent a localized string, so it stays quiet rather
than announcing English into every app. The label is a live region, which means
it is read when the skeleton appears rather than only when focus reaches it.
Skeleton primitives
| Widget | Shape |
|---|---|
SkeletonBox(width, height, borderRadius) |
Rounded rectangle |
SkeletonCircle(size) |
Circle, e.g. avatar |
SkeletonLine(width, height) |
Pill-shaped text line |
All take a color (default: a light gray for the shimmer to paint over). Null
width/height fills the available space when the incoming constraints are
bounded.
Where a scope is the wrong tool
- One
Shimmeralready wraps the whole list. If every row is the same width and they all sit under a single widget, that widget is already the frame the sweep travels across, and a scope has nothing left to change. - Two widgets need different speeds. One clock means one
period; a widget that wants its own pace has to sit outside the scope. - The sweep is frozen over scrolling content. While the band is held still, by
enabled: falseor by reduced motion, nothing repaints it: it stays with its widget until something else triggers a repaint. - Scopes are nested. A
Shimmerbinds to the nearest one above it, which is usually what you want and does mean an inner scope quietly wins.
Notes
- A custom
Gradientpassed to the default constructor is used exactly as given; the sweep comes from sliding the paint window across the child, which is why it applies to any gradient type. loop: 0(default) repeats until the widget is disposed orenabled: false.
Credits
The API design and sweep geometry follow the shimmer package by HungHD (hnvn); this is an independent implementation.
License
MIT
Libraries
- skeleton_shimmer
- Shimmer loading effect with an API compatible with the
shimmerpackage, plus a shared sweep across widgets, skeleton placeholder primitives and reduced-motion support.