image

18 Feb 2026

9K

35K

Flutter Circular Loader Animation with Gradient Color

In modern applications, user experience is paramount. A crucial element in maintaining user engagement during data fetching or intensive computations is an effective loading indicator. While standard circular progress indicators are functional, they often lack visual flair. This article will guide you through creating a custom, visually appealing circular loader in Flutter, featuring a smooth animation and a vibrant gradient color, significantly enhancing your app's UI.

The Need for Engaging Loaders

Default loading spinners can be monotonous. A custom loader with a gradient and fluid animation not only serves its functional purpose but also adds a layer of professionalism and brand identity to your application. It transforms a mundane waiting period into a more pleasant visual experience, reducing perceived loading times.

Core Concepts for Custom Drawing and Animation

To achieve our gradient circular loader, we will leverage several core Flutter concepts:

  • CustomPainter: This class allows us to draw custom graphics directly onto the canvas. It's perfect for creating unique shapes and applying custom styles that are not readily available through standard widgets.
  • AnimationController: Manages the animation. It can play, stop, reverse, and repeat an animation over a specified duration.
  • Animation<double>: Represents a value that changes over time, driven by an AnimationController. We'll use it to animate the sweep angle of our circular loader.
  • AnimatedBuilder: A widget that rebuilds its child subtree whenever the animation it listens to changes value. This is an efficient way to update only the parts of the UI that depend on the animation.
  • SweepGradient: A type of gradient that sweeps colors around a central point, ideal for circular progress indicators.

Step-by-Step Implementation

1. Project Setup and Widget Structure

First, let's create a new Flutter project or open an existing one. We will implement our custom loader as a StatefulWidget to manage its animation state.


import 'dart:math' as math;
import 'package:flutter/material.dart';

class GradientCircularLoader extends StatefulWidget {
  final double size;
  final double strokeWidth;
  final List gradientColors;
  final Color backgroundColor;
  final Duration duration;

  const GradientCircularLoader({
    Key? key,
    this.size = 100.0,
    this.strokeWidth = 10.0,
    this.gradientColors = const [Colors.blue, Colors.purple],
    this.backgroundColor = Colors.grey,
    this.duration = const Duration(seconds: 2),
  }) : super(key: key);

  @override
  _GradientCircularLoaderState createState() => _GradientCircularLoaderState();
}

class _GradientCircularLoaderState extends State
    with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation _animation;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: widget.duration,
    )..repeat(); // Loop indefinitely

    _animation = Tween(begin: 0.0, end: 1.0).animate(_animationController);
  }

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: SizedBox(
        width: widget.size,
        height: widget.size,
        // The AnimatedBuilder will go here
        child: Container(), // Placeholder for now
      ),
    );
  }
}

2. The Animation Controller

In the _GradientCircularLoaderState, we initialize an AnimationController. The vsync parameter is crucial for efficient animation, preventing animations from running when the widget is not visible. SingleTickerProviderStateMixin provides the TickerProvider needed for vsync. We use .repeat() to make the animation loop continuously.

The _animation variable is a Tween that defines the range of values (0.0 to 1.0) our animation will progress through, driven by the controller.

3. Crafting the Custom Painter

This is where the visual magic happens. We'll create a CircularLoaderPainter that extends CustomPainter. This painter will be responsible for drawing the background circle and the animating arc with a gradient.


class CircularLoaderPainter extends CustomPainter {
  final double animationValue;
  final Color backgroundColor;
  final List gradientColors;
  final double strokeWidth;

  CircularLoaderPainter({
    required this.animationValue,
    required this.backgroundColor,
    required this.gradientColors,
    required this.strokeWidth,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = math.min(size.width, size.height) / 2 - strokeWidth / 2;

    // Background circle paint
    final backgroundPaint = Paint()
      ..color = backgroundColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;

    // Gradient arc paint
    final gradientPaint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round
      ..shader = SweepGradient(
        startAngle: 0.0,
        endAngle: math.pi * 2, // Full circle for the sweep gradient
        colors: gradientColors,
        transform: GradientRotation(-math.pi / 2), // Start from the top
      ).createShader(Rect.fromCircle(center: center, radius: radius));

    // Draw background circle
    canvas.drawCircle(center, radius, backgroundPaint);

    // Draw animating arc
    final double startAngle = -math.pi / 2; // Start from the top (12 o'clock)
    final double sweepAngle = 2 * math.pi * animationValue; // Animate full circle

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngle,
      sweepAngle,
      false, // UseCenter - set to false for an arc, true for a pie slice
      gradientPaint,
    );
  }

  @override
  bool shouldRepaint(covariant CircularLoaderPainter oldDelegate) {
    return oldDelegate.animationValue != animationValue ||
           oldDelegate.backgroundColor != backgroundColor ||
           oldDelegate.gradientColors != gradientColors ||
           oldDelegate.strokeWidth != strokeWidth;
  }
}

In the paint method:

  • We calculate the center and radius based on the available size.
  • A backgroundPaint draws a static gray circle.
  • gradientPaint is where the magic happens for the animating arc. We assign a SweepGradient to its shader property. GradientRotation(-math.pi / 2) rotates the gradient to start from the top.
  • drawArc then draws the main animating arc. The startAngle is -math.pi / 2 (which corresponds to 12 o'clock), and the sweepAngle is determined by our animationValue, making it grow from 0 to a full circle.

The shouldRepaint method is optimized to only repaint when the relevant properties have changed, improving performance.

4. Integrating with AnimatedBuilder

Finally, we integrate our CustomPainter into the widget tree using an AnimatedBuilder. This ensures that only the CustomPaint widget (and thus the painter) rebuilds when the animation value changes, rather than the entire widget tree.

Replace the Container() placeholder in the build method of _GradientCircularLoaderState:


  @override
  Widget build(BuildContext context) {
    return Center(
      child: SizedBox(
        width: widget.size,
        height: widget.size,
        child: AnimatedBuilder(
          animation: _animation,
          builder: (context, child) {
            return CustomPaint(
              painter: CircularLoaderPainter(
                animationValue: _animation.value,
                backgroundColor: widget.backgroundColor,
                gradientColors: widget.gradientColors,
                strokeWidth: widget.strokeWidth,
              ),
            );
          },
        ),
      ),
    );
  }

5. Full Code Example

Below is the complete code for a customizable circular loader with a gradient animation. You can use this GradientCircularLoader widget in any part of your Flutter application.


import 'dart:math' as math;
import 'package:flutter/material.dart';

class GradientCircularLoader extends StatefulWidget {
  final double size;
  final double strokeWidth;
  final List gradientColors;
  final Color backgroundColor;
  final Duration duration;

  const GradientCircularLoader({
    Key? key,
    this.size = 100.0,
    this.strokeWidth = 10.0,
    this.gradientColors = const [Colors.blue, Colors.purple],
    this.backgroundColor = Colors.grey,
    this.duration = const Duration(seconds: 2),
  }) : super(key: key);

  @override
  _GradientCircularLoaderState createState() => _GradientCircularLoaderState();
}

class _GradientCircularLoaderState extends State
    with SingleTickerProviderStateMixin {
  late AnimationController _animationController;
  late Animation _animation;

  @override
  void initState() {
    super.initState();
    _animationController = AnimationController(
      vsync: this,
      duration: widget.duration,
    )..repeat(); // Loop indefinitely

    _animation = Tween(begin: 0.0, end: 1.0).animate(_animationController);
  }

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

  @override
  Widget build(BuildContext context) {
    return Center(
      child: SizedBox(
        width: widget.size,
        height: widget.size,
        child: AnimatedBuilder(
          animation: _animation,
          builder: (context, child) {
            return CustomPaint(
              painter: CircularLoaderPainter(
                animationValue: _animation.value,
                backgroundColor: widget.backgroundColor,
                gradientColors: widget.gradientColors,
                strokeWidth: widget.strokeWidth,
              ),
            );
          },
        ),
      ),
    );
  }
}

class CircularLoaderPainter extends CustomPainter {
  final double animationValue;
  final Color backgroundColor;
  final List gradientColors;
  final double strokeWidth;

  CircularLoaderPainter({
    required this.animationValue,
    required this.backgroundColor,
    required this.gradientColors,
    required this.strokeWidth,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = math.min(size.width, size.height) / 2 - strokeWidth / 2;

    // Background circle paint
    final backgroundPaint = Paint()
      ..color = backgroundColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round;

    // Gradient arc paint
    final gradientPaint = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = strokeWidth
      ..strokeCap = StrokeCap.round
      ..shader = SweepGradient(
        startAngle: 0.0,
        endAngle: math.pi * 2, // Full circle for the sweep gradient
        colors: gradientColors,
        transform: GradientRotation(-math.pi / 2), // Start from the top
      ).createShader(Rect.fromCircle(center: center, radius: radius));

    // Draw background circle
    canvas.drawCircle(center, radius, backgroundPaint);

    // Draw animating arc
    final double startAngle = -math.pi / 2; // Start from the top (12 o'clock)
    final double sweepAngle = 2 * math.pi * animationValue; // Animate full circle

    canvas.drawArc(
      Rect.fromCircle(center: center, radius: radius),
      startAngle,
      sweepAngle,
      false, // UseCenter - set to false for an arc, true for a pie slice
      gradientPaint,
    );
  }

  @override
  bool shouldRepaint(covariant CircularLoaderPainter oldDelegate) {
    return oldDelegate.animationValue != animationValue ||
           oldDelegate.backgroundColor != backgroundColor ||
           oldDelegate.gradientColors != gradientColors ||
           oldDelegate.strokeWidth != strokeWidth;
  }
}

Conclusion

By combining Flutter's powerful custom painting capabilities with its robust animation framework, we've created a beautiful and engaging circular loader with a gradient. This custom component not only provides clear feedback to users during loading times but also significantly enhances the visual appeal and professionalism of your Flutter application. Experiment with different gradient colors, stroke widths, and animation durations to perfectly match your app's design language.

Related Articles

May 14, 2026

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter

Building a Multi-Event Countdown Timer Widget with Reminders, Notifications, Repeat, and Custom Labels in Flutter Countdown timers are essential in many applic

May 11, 2026

Unleashing Dynamic UIs: Flutter's Animation Prowess

Unleashing Dynamic UIs: Flutter's Animation Prowess for Slide & Scale Effects Flutter's declarative UI framework, combined with its powerful animation capabilit

May 11, 2026

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy

Building a Product Detail Page Widget in Flutter with Related Items, Review Carousel, Promo Badges, and Quick Buy A well-designed Product Detail Page (PDP) is