DEV3 Technologies home

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 Shimmer per screen is enough.
  • Respect reduced motion by checking MediaQuery.of(context).disableAnimations and skipping repeat().

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.

Related snippets