Mouse Cursor Trail Effect with Flutter
Sidney Aguirre
Posted on September 8, 2023
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.
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 .
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
andoffset
. Each element of the trail will be painted in a specific point in the canvas, that would be theOffset
. 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:
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...
),
);
}
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:
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á:
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
Posted on September 8, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.