Shimmer loading product grid
A dependency-free shimmer skeleton for product grids using a looping gradient ShaderMask while data loads.
FlutterFeedback and empty statesfluttershimmerskeletongrid
Code
import 'package:flutter/material.dart';
/// Sweeps a highlight across every skeleton box placed under it.
class Shimmer extends StatefulWidget {
const Shimmer({super.key, required this.child});
final Widget child;
@override
State<Shimmer> createState() => _ShimmerState();
}
class _ShimmerState extends State<Shimmer> with SingleTickerProviderStateMixin {
late final AnimationController _c =
AnimationController(vsync: this, duration: const Duration(milliseconds: 1400))
..repeat();
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final base = Theme.of(context).colorScheme.surfaceContainerHighest;
final glow = Theme.of(context).colorScheme.surface;
return AnimatedBuilder(
animation: _c,
child: widget.child,
builder: (context, child) => ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (rect) => LinearGradient(
colors: [base, glow, base],
stops: const [0.25, 0.5, 0.75],
// Slide the gradient from fully left to fully right of the box.
transform: _Slide(_c.value),
).createShader(rect),
child: child,
),
);
}
}
class _Slide extends GradientTransform {
const _Slide(this.t);
final double t;
@override
Matrix4? transform(Rect bounds, {TextDirection? textDirection}) =>
Matrix4.translationValues(bounds.width * (t * 3 - 1.5), 0, 0);
}
class ShimmerProductGrid extends StatelessWidget {
const ShimmerProductGrid({super.key, this.count = 6});
final int count;
@override
Widget build(BuildContext context) {
return Shimmer(
child: GridView.builder(
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, mainAxisSpacing: 12, crossAxisSpacing: 12, childAspectRatio: 0.72,
),
itemCount: count,
itemBuilder: (_, _) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _box(double.infinity, radius: 16)),
const SizedBox(height: 8),
_box(110, h: 12),
const SizedBox(height: 6),
_box(60, h: 12),
],
),
),
);
}
Widget _box(double w, {double h = 0, double radius = 6}) => Container(
width: w,
height: h == 0 ? null : h,
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(radius)),
);
}
How it works
What it solves
Spinners on a catalogue screen make the page feel empty and then jump when data arrives. A skeleton that mirrors the final card layout feels faster and avoids layout shift.
How it works
Shimmer wraps any subtree in one ShaderMask. A looping AnimationController feeds a custom GradientTransform that slides a three-stop gradient across the masked area; with BlendMode.srcATop the gradient paints only where the white placeholder boxes are. Because the mask sits above the whole grid, all cards sweep in sync and only one shader is created per frame.
ShimmerProductGrid uses the same grid delegate as the real list, so the placeholder occupies exactly the same space.
Adapting it
Swap the colours for your theme: they come from the colour scheme, so dark mode works already. Use the Shimmer wrapper around lists, avatars or text rows too; only the child boxes change. Switch between skeleton and data with AnimatedSwitcher keyed on the loading flag.
Pitfalls
- Placeholder boxes must be opaque white-ish colours, otherwise the mask has nothing to tint.
- Keep the controller out of long lists; one
Shimmerper screen is enough. - Respect reduced motion by checking
MediaQuery.of(context).disableAnimationsand skippingrepeat().
How to use it
Drop the file into lib/widgets/. No packages are needed.
body: loading ? const ShimmerProductGrid() : ProductGrid(items: items),
Match crossAxisCount and childAspectRatio to your real grid.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.