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
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 headingA first stab
The 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
<Table>s, we can create
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,
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
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
And here's what it looks like:
This looks alright, but I know we can do better.
Link to this headingSprings 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
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
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 headingDisconnected boops
On 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 headingStarting from the consumer
Let'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
- 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 breaking
useMemocomponents. Because we don't know how the trigger function will be used, this seems like a prudent bit of forethought.
Link to this headingBack to the component
This 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 headingKeeping it accessible
The 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 headingYours to discover
First: 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 headingBonus: That star animation
In 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 headingTroubleshooting
If 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 headingNothing happens
If 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.
June 10th, 2021