shimmer effect app and website

Mastering Skeleton Screens: Building Custom Shimmer Effects in Flutter

Why Shimmer Beats the Loading Spinner

Loading spinners are a relic of the early mobile era. Modern applications use skeleton screens—often called shimmer effects—to give users a sense of progress and structure while data fetches. Instead of a generic rotating icon, users see a ghostly preview of the content layout. While the Flutter pub.dev ecosystem has several shimmer packages, building your own offers superior control over performance, reduces app size, and allows for brand-specific customization.

The Core Mechanics: AnimationController and ShaderMask

A shimmer effect is achieved by sliding a linear gradient across a placeholder widget. In Flutter, we can implement this efficiently by combining an AnimationController with a ShaderMask. The ShaderMask widget allows us to apply a custom shader (in our case, a gradient) over any child widget tree, treating the child's alpha channel as a mask.

Step 1: The Shimmer Widget Wrapper

We start by creating a stateful widget that manages an infinite animation. We use SingleTickerProviderStateMixin to handle the vsync, ensuring the animation only runs when the screen is active to save battery.

class ShimmerLoading extends StatefulWidget {
  final Widget child;
  const ShimmerLoading({required this.child, Key? key}) : super(key: key);

  @override
  State<ShimmerLoading> createState() => _ShimmerLoadingState();
}

class _ShimmerLoadingState extends State<ShimmerLoading> with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController.unbounded(vsync: this)
      ..repeat(min: -0.5, max: 1.5, period: const Duration(milliseconds: 1200));
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return ShaderMask(
          blendMode: BlendMode.srcIn,
          shaderCallback: (bounds) {
            return LinearGradient(
              begin: Alignment.centerLeft,
              end: Alignment.centerRight,
              colors: [Colors.grey[300]!, Colors.grey[100]!, Colors.grey[300]!],
              stops: const [0.1, 0.5, 0.9],
              transform: _SlidingGradientTransform(offset: _controller.value),
            ).createShader(bounds);
          },
          child: widget.child,
        );
      },
    );
  }
}

Step 2: Driving the Gradient with a Custom Transform

To move the gradient, we need a custom GradientTransform. This class calculates the translation matrix based on the controller’s current value, moving the "light" part of our shimmer from left to right across the widget's bounds.

class _SlidingGradientTransform extends GradientTransform {
  final double offset;
  const _SlidingGradientTransform({required this.offset});

  @override
  Matrix4? transform(Rect bounds, {TextDirection? textDirection}) {
    return Matrix4.translationValues(bounds.width * offset, 0.0, 0.0);
  }
}

Implementation and Best Practices

To use this effect, simply wrap your skeleton placeholder widgets with ShimmerLoading. The color of the placeholder widgets doesn't matter much because the ShaderMask with BlendMode.srcIn will replace the pixels with the gradient. However, it is best practice to keep the placeholder shapes (Rectangles, Circles) identical to the final content to avoid layout shifts.

Widget buildSkeletonItem() {
  return ShimmerLoading(
    child: Padding(
      padding: const EdgeInsets.all(16.0),
      child: Row(
        children: [
          Container(width: 50, height: 50, color: Colors.white),
          const SizedBox(width: 12),
          Expanded(
            child: Container(height: 20, color: Colors.white),
          ),
        ],
      ),
    ),
  );
}

By implementing your own shimmer, you avoid the overhead of heavy animation libraries. This approach is highly performant because it leverages the underlying Skia/Impeller engine's ability to handle shaders efficiently. For complex lists, remember to use ListView.builder to ensure that shimmer animations only run for items currently visible on the viewport.