skeleton_shimmer

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

A skeleton inbox: a header block and five rows of avatar and text
placeholders with one highlight sweeping across them, then the real
conversations fading
in

A skeleton inbox: a header block and five rows of avatar and text placeholders with the highlight sweeping across, then the real conversations fading in

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,
        ),
    ],
  ),
)

The same five-card dashboard skeleton rendered twice at four points of one sweep. On the left each card has its own Shimmer and every card carries the highlight at the same moment. On the right one ShimmerScope covers all of them and a single band travels across the row, lighting the left card, then the middle, then the right

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,
      // Your row, not ours: this package paints the sweep over whatever
      // you put here.
      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

Four frames of one sweep side by side: on the left the highlight band advances across the placeholder card, on the right the same card stays flat base gray because the platform asked for 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 MediaQuery with copyWith. A fresh MediaQueryData(...) anywhere above a Shimmer resets disableAnimations to 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 Shimmer already 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: false or by reduced motion, nothing repaints it: it stays with its widget until something else triggers a repaint.
  • Scopes are nested. A Shimmer binds to the nearest one above it, which is usually what you want and does mean an inner scope quietly wins.

Notes

  • A custom Gradient passed 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 or enabled: 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 shimmer package, plus a shared sweep across widgets, skeleton placeholder primitives and reduced-motion support.