Use Hover to Track Time Mouse Is Over Element
Introduction
Hover animations are a great way to make an application feel dynamic and responsive. It's a small thing, but it's exactly the kind of little detail that, in aggregate, can make a product feel great.
Sometimes, though, a simple state change on mouse-enter doesn't quite work. Hover over these icons to see what I mean:
Maybe it's the asymmetry, but these hover states just don't feel good to me π¬
Instead, what if the icons only popped over to their hover state for a brief moment?
I love this effect. It's playful and dynamic and surprising. It's not commonly done, since it's significantly more complex than using transition
.
It can be used in all kinds of nifty ways. Some examples:
After an informal Twitter poll, it was decided that this effect would be called a "boop".
In this tutorial—which is intended for intermediate React users—we'll learn how to build it ✨
Link to this heading
A first stabThe neat thing about component-driven frameworks like React is that we can package up behaviours in much the same way that we package UI elements. In addition to <Button>
s and <Table>
s, we can create <FadeIn>
s and <SoundEffect>
s.
In our case, the effect—quickly applying and then removing a transformation—can be divorced from any specific UI elements, so we can apply it to anything!
Here's a first shot at a React component:
This is a lot of code, so let's walk through it!
The fundamental idea is that when mousing over this element, it flips to an alternative state, just like a typical hover transition. In addition, though, it also starts a timer. When that timer elapses, the state flips back to the "natural" state, regardless of whether we've still hovering or not.
It's a bit like one of those "useless machines" that turns itself off after a short interval:
We keep track of the "boop" state with a state hook, isBooped
.
We wrap the thing we want to boop — children
— in a span. This is so we can apply the rotation style, as well as handle mouse events to trigger the effect in the first place.
We use an effect hook which is set to fire whenever isBooped
changes. Our hover event causes this value to flip, which causes the effect hook to trigger. The effect hook schedules a timeout to flip isBooped
back to false.
What about the effect itself? For now, we're limiting it to rotation. When isBooped
is true, we apply a transform: rotate
to the wrapping element.
We control both the rotation amount, in degrees, and the transition length through props, since different situations might call for different effects. We also need to set display
to inline-block
, because inline
elements aren't transformable, and we add backface-visibility: hidden
to take advantage of hardware acceleration.
Here's how we'd use our new Boop
component:
And here's what it looks like:
This looks alright, but I know we can do better.
Link to this heading
Springs to the rescue!The motion in this initial version feels robotic and artificial to me. It doesn't have the fluid, organic movement that I crave from modern web animations.
In A Friendly Introduction to Spring Physics, I shared how I add depth and realism to my animations. If you haven't already, I'd suggest checking it out! It features these fun little springy demos:
(✨ Drag and release the weights to see the animation ✨)
My favourite spring-physics animation library is React Spring. It offers a modern hook-based API, and unbeatable performance. Let's update our snippet to use it instead of CSS transitions:
Before, we were creating a style
object and passing it directly onto our span. Now, we're passing that style object (without transition
) into useSpring
.
The useSpring
hook can be thought of as one of those industrial machines that squirts the strawberry filling into pop-tarts:
In other words, it takes some plain CSS and injects ✨ spring magic ✨ into it. Instead of using the BΓ©zier curves that CSS provides, it'll use spring math instead. That's why we omit the transition
property; we're delegating that task to React Spring.
Because spring physics aren't a native part of the web (yet!), we can't pass this magic-injected style object onto a <span>
. Instead, we render an <animated.span>
, which is identical to the <span>
we had before, except it knows how to handle the springy style object we've produced.
Here's the result:
This feels a bit sluggish, so let's tweak the configuration:
By cranking up the tension and lowering the friction, our icons react much more swiftly to being hovered over:
Now we're getting somewhere!
So far, we've limited our boop to affect rotation, but we can do a lot more than that! Let's update it to support size changes (via scale
) and position shifts (via translate
):
The transform
CSS property accepts multiple space-separated values, so our code becomes:
We default all values to their natural state (eg. 0px translate, 1x scale). This allows us to only specify the values we want to change: if we don't pass a value for rotation, it won't rotate.
I feel pretty happy with this result, but there's a problem with this solution… And it's a significant one. In fact, we need to rethink our whole approach!
Link to this heading
Disconnected boopsOn the project I'm working on, I have widgets that can be expanded to show the full content. I thought it'd be fun to cause the caret to skip down a bit on hover:
This presents an interesting challenge, because there's a disconnect—I want the boop to affect only the caret, but it should be triggered whenever I mouse-over any part of it. If I wave my cursor over the word "Show", the caret should boop.
Our current approach doesn't allow for this at all. The animation is bound to the same element as the event-handler.
After some experimentation, I realized that a hook, not a component, was the right API for this effect.
Link to this heading
Starting from the consumerLet's start from the perspective of someone using the API. I'll figure out how to implement it later; first, I want to figure out the simplest, easiest interface.
Here's what I came up with:
We should be able to pass our hook an object representing the config, and it should give us two things:
-
The style object, to be applied to an
animated
element, likeanimated.span
oranimated.button
-
A trigger function, to call whenever we want the boop to occur.
If we want, we can apply both of these things to the same element, but we don't have to! In fact, this hook gives us a ton of flexibility: we can trigger it whenever we want, not just on hover. For example, we can include mobile users by setting the effect on tap, or schedule it in an interval to add prominence to an important part of the UI!
Here's how it's implemented:
Much of this logic is copied over; we're doing the same work to produce that style
object. Instead of applying it onto an element, though, we pass it off to the caller.
Two other small tweaks:
-
The spring configuration is now provided as a parameter, since different situations might call for different physics.
-
The trigger function is wrapped in
React.useCallback
. This is done so that the function reference doesn't change between renders, to avoid breakinguseMemo
components. Because we don't know how the trigger function will be used, this seems like a prudent bit of forethought.
Link to this heading
Back to the componentThis hook is neat, but I actually really liked the component API we came up with earlier. In cases where there isn't a disconnect between event-handler and animation, can we use a component instead?
The really cool thing about this pattern is we can easily wrap the hook in a component, to have our cake and eat it too:
Our Boop component gets a whole lot smaller, since we're delegating all the hard stuff to our useBoop
hook. Now we have access to two glorious APIs, both powered by the same logic. DRY AF.
Link to this heading
Keeping it accessibleThe component/hook combo we've created is delightful, but delight is subjective. Not everybody wants our UI to dance and jiggle about, especially folks who have a vestibular disorder.
I've written about how to build accessible animations in React. Let's apply some of those lessons here:
The prefers-reduced-motion hook will let us know if the user has expressed a preference to remove motion. If that value is true
, we'll return a "dummy" style object. This ensures that the element will never move, since the style object is always empty.
Link to this heading
Yours to discoverFirst: thank you so much for reading this far!! This has been quite a journey, and I appreciate you for taking it with me π
You might be wondering, though: why on earth did we need to cover this in such depth? Why didn't I just publish an NPM module and write a post explaining how to use it, like I did with useSound? Surely that would be more convenient, both for the reader and the author.
Here's the thing: this effect is effective because it's rare. I'm not interested in commoditizing it, because it would lose its charm!
Instead, I'd much rather teach folks how to create effects like this, and let them run with it. This code will live in your Git repo, not buried in a node_modules
folder. Tinker with it, and see what else it can do! Create things I never could have anticipated, and show me on Twitter π
This code is mutable, and I hope you'll do some experimentation ✨ if you're really feeling adventurous, you could try and incorporate more physics: maybe the element should translate in the same direction as the cursor is moving, as if it was blowing in the breeze?
Here's the final version, ready to copy-and-paste into your repo:
Link to this heading
Bonus: That star animationIn the initial demos of this tutorial, I showcased a hoverable star animation:
This effect does indeed use the useBoop
hook we've created, but it also involves some trigonometry, which is beyond the scope of this tutorial. I'm in the process of writing a post about how to use trigonometry to create effects like this one—if you'd like to receive early access to that tutorial, and others like it, you can sign up for my newsletter:
My newsletter is sent about once a month, and it includes little extras that don't quite fit on this blog. You can, of course, unsubscribe at any time, no hurt feelings. π
In the meantime, though, I'll share the snippet, with as much context as I can in the comments! Hope it helps. π
Link to this heading
TroubleshootingIf you try to use this effect in your project, and it doesn't work, this section might help you diagnose the issue! If your issue isn't listed, feel free to reach out on Twitter.
Link to this heading
Nothing happensIf you don't see any motion, and no errors are reported, it's likely that you forgot to use animated
! I still make this mistake frequently.
Last Updated
June 10th, 2021
Hits
Use Hover to Track Time Mouse Is Over Element
Source: https://www.joshwcomeau.com/react/boop/