On GitHub, if you want to delete a repository, you're required to enter its name before GitHub allows you to press the final 'Delete' button. It's made this way to slow you down a bit and make sure you really know what you are doing.
It always irritated me a bit because you need to switch from mouse to keyboard. And on top of that, you don't even need to type the repository name, you can just copy & paste it right from the label above the input.
As an alternative for this type of interfaces, today we'll make 'Hold to confirm' button with React and Framer Motion. While it probably won't replace GitHub-like confirmation (at least if you care about accessibility), but it sure will be slick and pretty!
This article assumes you are already familiar with TypeScript, React and Framer Motion at a basic level.
Please note that the current version of framer-motion (11.0.14) has a bug that affects this project. So while it's not fixed, I recommend you use a specific version of framer-motion to follow this tutorial.
npm install framer-motion@11.0.8
Let's establish a foundation for our component.
And styles:
Most of the styles are here purely for visual appeal. However, there are a few exceptions, mainly user-select: none which blocks the user from selecting text on the button and touch-action: none which tells the browser to not handle any touch gestures on this element (like scrolling or zooming). And both .text and .filler have pointer-events: none so they won't intercept any pointer events, but will still be visible.
At this point, it should already look good, but it does absolutely nothing.
Now, let's get to implementing the main feature. We'll need to handle two separate scenarios: when the user uses a mouse and when they use a touchpad. For both, we'll use pointer events (instead of separate mouse and touch events) to reduce code duplication.
To track progress, we'll use the motion value. If you're not familiar with motion values, they are something between state and ref. Updates to motion value don't cause re-render, but motion components can react to changes in motion value and update their styles accordingly.
When the user touches the button or presses the mouse button, we need to start our countdown.
This involves changing the state to inProgress, so React will re-render the button with new text and start animating the value of progress to 1. animate returns promise, so we can wait for the animation to finish and do something. We're interested only in those cases where the animation is completed successfully (i.e., wasn't cancelled), and there we switch the state to complete. In this state, the user needs to release the touch/mouse over our button to confirm their intention or move it away to cancel.
To cancel the countdown, we need to stop the current progress animation, reset the state to idle and quickly animate progress back to 0.
When the user moves the mouse away from the button, the countdown is cancelled. This is handled by the onPointerLeave handler. But for touch, the browser won't tell us the moment the user moves their finger away from the button. All we have is a generic onPointerMove that will be fired for any movement. So we need to manually check if the element the user is currently touching is our button (or any of its descendants) or something else.
Final event handler is onPointerUp, where we either cancel the countdown if the user released the button too soon or confirm his intent and call onConfirm handler.
And lastly, let's show some feedback on the countdown to the user. For this, we'll also use motion value. This code goes inside our component:
And add the style attribute to the filler component:
Here, fillRightOffset is a derived motion value, as it's based on progress and will be updated every time the progress value changes. We then pass it to the style attribute of the motion component, to allow us to change element styles without re-rendering the whole component.
This is what we have at this stage. It's now functional, but still kind of confusing for the user. With a few visual improvements, we can better communicate to the user how this component works and just make it more visually appealing.
This component's intended use is to confirm dangerous actions. We, as developers, would like to really confirm that the user knows what they are doing. What about making our button try to run away when the user presses it? Well, not literally (but that would be fun too), but just make the button shake while the user presses it, like trying to shake off the cursor from it.
To animate something based on an ordinary variable (not a motion value) in Framer Motion, we use the animate property. To make the code a bit cleaner, we'll move our shaking animation out of the component and into a separate object. Such definitions in Framer Motion are called variants, and they can be reused across components.
Shaking is cute, but let's make it more immersive. For mobile users, at least. As we can make their phone vibrate with JavaScript :)
The next step is to add animation to the text. Currently, it changes without any effect. Not cool. For this, we'll also use variants, as well as AnimatePresence and direction-aware animations. I described this technique in one of my previous posts.
And finally, let's add a confirmation animation. When action is cancelled, our filler component shrinks from right to left, rolling back to its initial state. How about we make it go the opposite way for confirmation? For this, we'll need to animate the left style property and have a separate motion value for it.
Boom! That's it. This is a final result.
By the way, all the source code can be found here.
Published at 25 March 2024
Was this interesting or useful?
I publish a newsletter with articles I found interesting and announces of my new posts. You can leave your email and get new issues delivered to your inbox!
Alternatively you can subscribe to my RSS feed to know about new posts.
Or follow me on Twitter, where I sometimes post about new articles, my pet projects, and web dev in general.
If you really like the article, you can give me monies, and I'll buy myself tasty coffee to write even more.