Mouse Cursor Trail Effect with Flutter

sidney_pao

Sidney Aguirre

Posted on September 8, 2023

Mouse Cursor Trail Effect with Flutter

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

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

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;
}


Enter fullscreen mode Exit fullscreen mode

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();
  }
}


Enter fullscreen mode Exit fullscreen mode
  • 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,
      ),
    );
  }
}


Enter fullscreen mode Exit fullscreen mode

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

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 from one of Google Flutter Codelabs. 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...
       ),
    );
}


Enter fullscreen mode Exit fullscreen mode

star pointer

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,
        ),
      ),
    );
  }
}


Enter fullscreen mode Exit fullscreen mode

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,
      ),
    );
  }
}


Enter fullscreen mode Exit fullscreen mode

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

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,
          ),
        ),
      ),
    );
  }
}


Enter fullscreen mode Exit fullscreen mode

Reload and Voilá:

trail

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();
  }
}


Enter fullscreen mode Exit fullscreen mode

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,
          ),
        ),
      ),
    );
  }
}


Enter fullscreen mode Exit fullscreen mode

The end! I hope you enjoyed this 🥹

References

💖 💪 🙅 🚩
sidney_pao
Sidney Aguirre

Posted on September 8, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related