Mouse Cursor Trail Effect with Flutter
Published: 2023-09-08 16:59:59

Hello World!

If you ask me for one of the UI effects that I enjoy the most I’d have to say the mouse trailing effect.
It looks like a tail of the mouse cursor as it moves around the screen. Somehow like when you see a comet in the sky.


![commet](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4bafdgptj2254nc03z0x.png)

Today we will learn how to create this effect with Flutter.

> PS: If you were thinking that this works with animations, you are totally right. We’ll use some built-in Flutter animated widgets


**Cursor Trail Main Idea**

For our cursor trail, imagine that we have a paper sheet (our canvas) that the mouse cursor is our pencil, and that the trail is the trace we paint with our pencil .

![pencil trail](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4q0c61il3bup4sg9u8ha.png)

With this in mind, let’s begin:

**Implementation**

On your Flutter project, add a new dart file called cursor_animated_trail.dart . Here we will add all the logic and clases to bring that trail to reality.

- first, let’s create a class to handle the looks of each component in the trail. This will be called `AnimatedCursorTrailState` , here we will set our widget looks state. `decoration`, `size` and `offset`.
Each element of the trail will be painted in a specific point in the canvas, that would be the `Offset`. a pair of points x and y [:Offset(dx, dy)] understood as the position of an element in the screen.

```
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class AnimatedCursorTrailState {
  const AnimatedCursorTrailState({
    this.decoration = defaultDecoration,
    this.offset = Offset.zero,
    this.size = defaultSize,
  });

  static const Size defaultSize = Size(20, 20);
  static const BoxDecoration defaultDecoration = BoxDecoration(
    color: Colors.pink,
  );

  final BoxDecoration decoration;
  final Offset offset;
  final Size size;
}
```

> Note: Until here, I haven’t said what the widget is specificly, just how it might look like, not what it looks like yet.

Now, we’ll add a `AnimatedCursorTrailProvider` class on which we will handle the behavior of our cursor in order to paint the trail. This class extends from `ChangeNotifier` as we need other to be notified of that behavior so they can react accordingly.

```
class AnimatedCursorTrailProvider extends ChangeNotifier {
  AnimatedCursorTrailProvider();

  AnimatedCursorTrailState state = AnimatedCursorTrailState();

  void updateCursorPosition(Offset position) {
    state = AnimatedCursorTrailState(offset: position);
    notifyListeners();
  }
}
```

- In this class, first, we create an instance of `AnimatedCursorTrailState`.
- then we have one `methodupdateCursorPosition()` that will update the position of our trail.

**Trail**

Now let’s create the trail itself.

```
class AnimatedCursorTrail extends StatelessWidget {
  const AnimatedCursorTrail({
    super.key,
    this.child,
  });

  final Widget? child;

  void _onCursorUpdate(BuildContext context, PointerEvent event) =>
      context.read<AnimatedCursorTrailProvider>().updateCursorPosition(
            event.position,
          );

  List<Widget> _trail(AnimatedCursorTrailProvider provider) {
    final result = <Widget>[];

    for (var index = 0; index < provider.listTrail.length; index++) {
      if (index % 10 == 1) {
        result.add(provider.listTrail.elementAt(index));
      }
    }

    return result;
  }

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => c(),
      child: Consumer<AnimatedCursorTrailProvider>(
        builder: (context, provider, child) {
          final state = provider.state;

          return Stack(
            children: [
              if (child != null) child,
              Positioned.fill(
                child: MouseRegion(
                  onHover: (event) {
                    _onCursorUpdate(context, event);
                    provider.listTrail.add(
                      AnimatedTrail(offset: event.position),
                    );
                  },
                  opaque: false,
                ),
              ),
              AnimatedPositioned(
                left: state.offset.dx - state.size.width / 2,
                top: state.offset.dy - state.size.height / 2,
                width: state.size.width,
                height: state.size.height,
                duration: Duration(milliseconds: 50),
                child: IgnorePointer(
                  child: Icon(
                      Icons.star,
                      color: state.decoration.color,
                      size: state.size.height,
                  ),
                ),
              ),
            ],
          );
        },
        child: child,
      ),
    );
  }
}
```

So, we have the `AnimatedCursorTrail` stateless widget that receives a child as a parameter, this is because this widget will work as a **wrapper of our content** and will paint the trail where we want it.

in the `build` method, we have a `ChangeNotifierProvider` widget whose child is a `Consumer` of `AnimatedCursorTrailProvider` . This means that `ChangeNotifierProvider` will be able to listen when the state changes in `AnimatedCursorTrailProvider`, exposing this changes to its descendants.

The `Consumer` child will get the `Provider` from its parent and will pass it to the builder so it can take the changes and animate the trail.

in the builder we return a `Stack` widget whose deepest element will be `child` , then we have a `MouseRegion` widget that will capture the mouse movement event and a our trail.

The actual trail component is this piece here:

![actual trail component](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wa78x1ysn663qs91a93u.png)

As you noticed, this trail is an `AnimatedPositioned` that takes the offset of our pointer (captured in the `Consumer` builder method) and moves our trail widget towards the pointer, that’s why its a trail, ain’t it?

This `AnimatedPositioned` is wrapping an `IgnorePointer` widget so our clicking events are not interfered. There is some star icon there, probably how the trail will look like.

In order to see the result on the screen of what we just did, let’s use the wrapper we mention before, Just wrap your widget with `AnimatedCursorTrail`, and Voilá!

In my case, the project I have is this [Namer app](https://codelabs.developers.google.com/codelabs/flutter-codelab-first?hl=en&authuser=2#6) from one of [Google Flutter Codelabs](https://codelabs.developers.google.com/?product=flutter). I wrapped the `GeneratorPage` with `AnimatedCursorTrail` , passed the page content as the child and this is the result:

```
Widget build(BuildContext context) {

   return AnimatedCursorTrail(
      child: Scaffold(
           //Page content...
       ),
    );
}
```

![star pointer](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bxhmvnhk8ymufchizts6.gif)

Oops! this doesn’t look like a trail at all! It looks like our star icon feels lonely so it just follows the pointer, and that’s it 😅

Well, for the trail we can think of it as a repetition of a widget. Each widget will be painted in a specific point in the canvas, right?
but if “Trail ::= a repetition of a widget”, how can we multiply a widget?

Simple! We add the same widget multiple times to some List and paint every element on the list. So, With this in mind let’s modify a little bit our implementation:

First, Let’s move our tail widget out to a new class:

```
class AnimatedTrail extends StatelessWidget {
  const AnimatedTrail({
    super.key,
    required this.offset,
  });

  final Offset offset;

  @override
  Widget build(BuildContext context) {
    return Positioned(
      left: widget.offset.dx,
      top: widget.offset.dy,
      child: IgnorePointer(
        child: Icon(
          Icons.star,
          color: AnimatedCursorTrailState.defaultDecoration.color,
          size: AnimatedCursorTrailState.defaultSize.height,
        ),
      ),
    );
  }
}
```

As you see, We no longer have an `AnimatedPositioned` widget, since this trail individual widget will be at a specific point in the screen, right?

**List strategy to build Trail**

Let’s say that inside our AnimatedCursorTrail widget, we create an empty list in which we will add the trail components as the cursor moves, like this:

```
class AnimatedCursorTrail extends StatelessWidget {
  ...
  
  final trail = <Widget>[]; // Here 

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      ...

          return Stack(
            children: [
              if (child != null) child,
              Positioned.fill(
                child: MouseRegion(
                  onHover: (event) {
                    trail.add(                 // From here
                      AnimatedTrail(           // Add element to trail
                        offset: event.position,   
                      ),
                    );                         // To here
                    _onCursorUpdate(context, event);
                  },
                  opaque: false,
                ),
              ),
              for (var star in trail) star, // Trail here
            ],
          );
        },
        child: widget.child,
      ),
    );
  }
}
```

In the `onHover(event)` method, as the cursor moves we add a star icon element to create the trail, and paint the actual trail using a for loop.
Notice that we can see the changes without having a `StatefulWidget`?
That is because the method `_onCursorUpdate()` results in a notification to the `AnimatedCursorTrailProvider` subscribers.

If you reload and check, you’ll see that now we can draw a cursor trail 🥳 (although each point stays in the screen 🤨) something like this:


![doodle board](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/iqqmpnkxx2heb59z28es.png)

Humk, we don’t need a doodle board, 🤔 that's a different project!
(although, if that’s what you wanted, you got it already and can stop here 🤗)

For those looking for a Trail, we want each point to desapear nicely after some time. Ok! let's wrap our star icon with an `AnimatedOpacity` widget, since this will make that point fade out and avoids that it lingers in the screen. Now,

- Since this animated widget requires some updates in its state, we convert AnimatedTrail to a StatefulWidget.

- create a double value called opacityLevel initialized in 1.0, so the icon is fully visible once it’s created

- create a method changeOpacity() to assing 0.0 to opacityLevel so it becomes ‘invisible’

- inside the build method, call WidgetsBinding.instance.addPostFrameCallback in order to start animation right after the element is built.

You’ll get something like this:

```
class AnimatedTrail extends StatefulWidget {
  ...

  double opacityLevel = 1.0;

  void changeOpacity() {
    if (mounted) setState(() => opacityLevel = 0.0);
  }

  @override
  Widget build(BuildContext context) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      changeOpacity();
    });

    return Positioned(
      left: widget.offset.dx - 250,
      top: widget.offset.dy,
      child: IgnorePointer(
        child: AnimatedOpacity(
          duration: Duration(seconds: 3),
          curve: Curves.ease,
          opacity: opacityLevel,
          child: Icon(
            Icons.favorite,
            color: AnimatedCursorTrailState.defaultDecoration.color,
            size: AnimatedCursorTrailState.defaultSize.height,
          ),
        ),
      ),
    );
  }
}
```
Reload and Voilá:

![trail](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1womlyahmfy6yn5pide4.gif)

To organize our code better, let’s move the list (trail) to a place where it seems to belong and make things easier for us:


```
class AnimatedCursorTrailProvider extends ChangeNotifier {
  AnimatedCursorTrailProvider();

  AnimatedCursorTrailState state = AnimatedCursorTrailState();
  final trail = <Widget>[];

  void updateCursorPosition(Offset position) {
    state = AnimatedCursorTrailState(offset: position);
    notifyListeners();
  }
}
```

If you want, you can have some condition to paint the trail so it doen’t paint a star in every single point but on most of them. This will look smother too!

Here, I just add the items to the trail if index mod(30) is 0.


💫

Check the full implementation here:

```
import 'package:flutter/material.dart';
import 'package:my_website/my_website.dart';
import 'package:provider/provider.dart';

class AnimatedCursorTrailState {
  const AnimatedCursorTrailState({
    this.decoration = defaultDecoration,
    this.offset = Offset.zero,
    this.size = defaultSize,
  });

  static const Size defaultSize = Size(20, 20);
  static const BoxDecoration defaultDecoration = BoxDecoration(
    borderRadius: BorderRadius.all(
      Radius.circular(90),
    ),
    color: Colors.purple,
  );

  final BoxDecoration decoration;
  final Offset offset;
  final Size size;
}

class AnimatedCursorTrailProvider extends ChangeNotifier {
  AnimatedCursorTrailProvider();

  AnimatedCursorTrailState state = AnimatedCursorTrailState();
  final listTrail = <Widget>[];

  void changeCursor(GlobalKey key, {BoxDecoration? decoration}) {
    final renderBox = key.currentContext?.findRenderObject() as RenderBox?;

    if (renderBox == null) return;

    state = AnimatedCursorTrailState(
      decoration: decoration ?? AnimatedCursorTrailState.defaultDecoration,
      offset: renderBox.localToGlobal(Offset.zero).translate(
            renderBox.size.width / 2,
            renderBox.size.height / 2,
          ),
      size: renderBox.size,
    );

    notifyListeners();
  }

  void resetCursor() {
    state = AnimatedCursorTrailState();
    notifyListeners();
  }

  void updateCursorPosition(Offset position) {
    state = AnimatedCursorTrailState(offset: position);
    notifyListeners();
  }
}

class AnimatedCursorTrail extends StatelessWidget {
  const AnimatedCursorTrail({
    super.key,
    this.child,
  });

  final Widget? child;

  void _onCursorUpdate(BuildContext context, PointerEvent event) =>
      context.read<AnimatedCursorTrailProvider>().updateCursorPosition(
            event.position,
          );

  List<Widget> _trail(AnimatedCursorTrailProvider provider) {
    final result = <Widget>[];

    for (var index = 0; index < provider.listTrail.length; index++) {
      if (index % 30 == 0) {
        result.add(provider.listTrail.elementAt(index));
      }
    }

    return result;
  }

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => AnimatedCursorTrailProvider(),
      child: Consumer<AnimatedCursorTrailProvider>(
        builder: (context, provider, child) {
          return Stack(
            children: [
              if (child != null) child,
              Positioned.fill(
                child: MouseRegion(
                  onHover: (event) {
                    _onCursorUpdate(context, event);
                    provider.listTrail.add(
                      AnimatedTrail(offset: event.position),
                    );
                  },
                  opaque: false,
                ),
              ),
              ..._trail(provider),
            ],
          );
        },
        child: child,
      ),
    );
  }
}

class AnimatedCursorMouseRegion extends StatefulWidget {
  const AnimatedCursorMouseRegion({
    super.key,
    this.child,
  });

  final Widget? child;

  @override
  State<AnimatedCursorMouseRegion> createState() =>
      _AnimatedCursorMouseRegionState();
}

class _AnimatedCursorMouseRegionState extends State<AnimatedCursorMouseRegion> {
  late final AnimatedCursorTrailProvider _cubit;
  final GlobalKey _key = GlobalKey();

  @override
  void initState() {
    super.initState();

    _cubit = context.read<AnimatedCursorTrailProvider>();
  }

  @override
  void dispose() {
    _cubit.resetCursor();

    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      key: _key,
      opaque: false,
      onExit: (_) => _cubit.resetCursor(),
      child: widget.child,
    );
  }
}

class AnimatedTrail extends StatefulWidget {
  const AnimatedTrail({
    super.key,
    required this.offset,
  });

  final Offset offset;

  @override
  State<AnimatedTrail> createState() => _AnimatedTrailState();
}

class _AnimatedTrailState extends State<AnimatedTrail> {
  double opacityLevel = 1.0;

  void changeOpacity() {
    if (mounted) setState(() => opacityLevel = 0.0);
  }

  @override
  Widget build(BuildContext context) {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      changeOpacity();
    });

    return Positioned(
      left: widget.offset.dx - navBarWidth,
      top: widget.offset.dy,
      child: IgnorePointer(
        child: AnimatedOpacity(
          duration: Duration(seconds: 3),
          curve: Curves.ease,
          opacity: opacityLevel,
          child: Icon(
            Icons.favorite,
            color: AnimatedCursorTrailState.defaultDecoration.color,
            size: AnimatedCursorTrailState.defaultSize.height,
          ),
        ),
      ),
    );
  }
}
```

The end! I hope you enjoyed this 🥹

**References**

- Cursor Mouse Follow Animation in Flutter -> https://www.youtube.com/watch?v=CCrmRG-dIfY