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.
Table of contents
Setup
Let's establish a foundation for our component.
import { motion } from 'framer-motion';
import './styles.css';
type HoldToConfirmProps = {
text: string;
confirmTimeout?: number; // seconds
onConfirm?: VoidFunction;
};
export const HoldToConfirmFoundation = ({ text: textFromProps, confirmTimeout = 2, onConfirm }: HoldToConfirmProps) => {
return (
<motion.button className="PressToConfirm">
<motion.div className="filler" />
<motion.div className="text">{textFromProps}</motion.div>
</motion.button>
);
};
And styles:
:root {
font-size: 16px; /* => 1rem */
}
.PressToConfirm {
--bg-color: #dd3b38;
--fill-color: #ffffff33;
--bg-color-hover: #d42e2c;
--border-color: #c33532;
--shadow-color: #580c0ca6;
--text-color: white;
box-sizing: border-box;
white-space: nowrap;
font-weight: 500;
font-family: inherit;
font-size: 0.875rem;
text-align: center;
cursor: pointer;
transition: 0.1s ease-in-out;
background-color: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 0.4rem;
color: var(--text-color);
line-height: 1.25rem;
padding: 0.35rem 1rem;
box-shadow: 0 1px 2px 0 var(--shadow-color);
position: relative;
overflow: hidden;
min-width: 200px;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.PressToConfirm:hover:not(.active) {
background-color: var(--bg-color-hover);
}
.PressToConfirm:focus {
outline-offset: 6px;
}
.PressToConfirm:focus-visible {
box-shadow: none;
}
.PressToConfirm .filler {
background-color: var(--fill-color);
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 100%;
pointer-events: none;
}
.text {
user-select: none;
-webkit-user-select: none;
pointer-events: none;
}
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.
Make it functional
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.
import { useRef, useState, PointerEvent } from 'react';
import { AnimatePresence, Variants, animate, motion, useMotionValue, useTransform } from 'framer-motion';
export const HoldToConfirmFoundation = ({ text: textFromProps, confirmTimeout = 2, onConfirm }: HoldToConfirmProps) => {
const startCountdown = () => {
/* ... */
};
const cancelCountdown = () => {
/* ... */
};
const pointerUp = (e: PointerEvent) => {
/* ... */
};
const pointerMove = (e: PointerEvent) => {
/* ... */
};
const [state, setState] = useState<'idle' | 'inProgress' | 'complete'>('idle');
const ref = useRef<HTMLButtonElement>(null);
const progress = useMotionValue(0);
const text = state === 'idle' ? textFromProps : state === 'inProgress' ? 'Hold to confirm' : 'Release to confirm';
return (
<motion.button
className="PressToConfirm"
// New props ⬇️
ref={ref}
onPointerDown={startCountdown}
onPointerUp={pointerUp}
onPointerCancel={cancelCountdown}
onPointerLeave={(e) => {
// For touchscreen browser always generates PointerLeave at
// the end of touch, even if it ended on the element, so
// we handle only mouse leave here
if (e.pointerType === 'mouse') cancelCountdown();
}}
onPointerMove={pointerMove}
// Prevent context menu on mobiles (caused by long touch)
onContextMenuCapture={(e) => e.preventDefault()}
>
<motion.div className="filler" />
<motion.div className="text">
{/* Show text depending on state */}
{text}
</motion.div>
</motion.button>
);
};
When the user touches the button or presses the mouse button, we need to start our countdown.
const startCountdown = () => {
setState('inProgress');
animate(progress, 1, { duration: confirmTimeout, ease: 'linear' }).then(() => {
if (progress.get() !== 1) return; // Animation cancelled
setState('complete');
});
};
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.
const cancelCountdown = () => {
progress.stop();
setState('idle');
animate(progress, 0, { duration: 0.2, ease: 'linear' });
};
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.
const pointerMove = (e: PointerEvent) => {
// Mouse will be handled by onPointerLeave
if (e.pointerType === 'mouse') return;
const target = document.elementFromPoint(e.clientX, e.clientY);
if (!ref.current?.contains(target)) {
cancelCountdown();
}
};
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.
const pointerUp = (e: PointerEvent) => {
const target = document.elementFromPoint(e.clientX, e.clientY);
if (progress.get() === 1 && ref.current?.contains(target)) {
progress.jump(0);
setState('idle');
onConfirm?.();
} else {
cancelCountdown();
}
};
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:
/* ... */
const progress = useMotionValue(0);
const fillRightOffset = useTransform(progress, (v) => `${(1 - v) * 100}%`);
/* ... */
And add the style
attribute to the filler component:
<motion.div
className="filler"
style={{
right: fillRightOffset,
}}
/>
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.
Make it pretty
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.
const buttonVariants: Variants = {
idle: {
x: 0,
rotate: 0,
transition: {
duration: 0.1,
},
},
shaking: {
x: [10, -10], // Keyframes: from 10 to -10 pixels
rotate: [-3, 3], // Keyframes: from -3 to 3 degrees
// This settings make our button shake indefinetely
transition: {
repeatType: 'mirror',
repeat: Infinity,
duration: 0.1,
ease: 'easeInOut',
},
},
};
export const HoldToConfirmFinal = ({ text: textFromProps, confirmTimeout = 2, onConfirm }: HoldToConfirmProps) => {
/* ... */
return (
<motion.button
className="PressToConfirm"
/* ... */
onContextMenuCapture={(e) => e.preventDefault()}
variants={buttonVariants}
animate={state === 'inProgress' ? 'shaking' : 'idle'}
>
{/* ... */}
</motion.button>
);
};
Shaking is cute, but let's make it more immersive. For mobile users, at least. As we can make their phone vibrate with JavaScript :)
const startCountdown = () => {
setState('inProgress');
// Generates array like [100, 50, 100, 50, 100, 50, ...] (100ms virate, 50ms silent, etc)
const pattern = new Array(confirmTimeout * 10).fill(0).map((_, ind) => (ind % 2 === 0 ? 100 : 50));
navigator.vibrate?.(pattern);
/* ... */
};
const cancelCountdown = () => {
navigator.vibrate?.(0); // Stop vibration
progress.stop();
/* ... */
};
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.
type Direction = 'back' | 'forward';
const textVariants: Variants = {
initial: (direction: Direction) => ({
y: direction === 'forward' ? '-30%' : '30%',
opacity: 0,
}),
target: {
y: '0%',
opacity: 1,
},
exit: (direction: Direction) => ({
y: direction === 'forward' ? '30%' : '-30%',
opacity: 0,
}),
};
export const HoldToConfirmFinal = ({ text: textFromProps, confirmTimeout = 2, onConfirm }: HoldToConfirmProps) => {
/* ... */
return (
<motion.button
className="PressToConfirm"
/* ... */
>
<motion.div
className="filler"
style={{
right: fillRightOffset,
}}
/>
<AnimatePresence custom={textDirection} initial={false} mode="popLayout">
<motion.div key={text} className="text" variants={textVariants} custom={textDirection} initial="initial" animate="target" exit="exit">
{text}
</motion.div>
</AnimatePresence>
</motion.button>
);
};
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.
export const HoldToConfirmFinal = ({ text: textFromProps, confirmTimeout = 2, onConfirm }: HoldToConfirmProps) => {
// We need to update pointerUp to play our confirmation animation
// and reset motion values only when it finishes
const pointerUp = (e: PointerEvent) => {
const target = document.elementFromPoint(e.clientX, e.clientY);
if (progress.get() === 1 && ref.current?.contains(target)) {
animate(fillerConfirmAnimationProgress, 1, { duration: 0.2, ease: 'linear' }).then(() => {
fillerConfirmAnimationProgress.jump(0);
progress.jump(0);
setState('idle');
onConfirm?.();
});
} else {
cancelCountdown();
}
};
/* ... */
const progress = useMotionValue(0);
const fillRightOffset = useTransform(progress, (v) => `${(1 - v) * 100}%`);
// This is used in 'completion' animation
const fillerConfirmAnimationProgress = useMotionValue(0);
const fillLeftOffset = useTransform(fillerConfirmAnimationProgress, (v) => `${v * 100}%`);
/* ... */
return (
<motion.button
className="PressToConfirm"
/* ... */
>
<motion.div
className="filler"
style={{
right: fillRightOffset,
left: fillLeftOffset,
}}
/>
<AnimatePresence custom={textDirection} initial={false} mode="popLayout">
{/* ... */}
</AnimatePresence>
</motion.button>
);
};
Boom! That's it. This is a final result.
By the way, all the source code can be found here.