Building a «hold to confirm» button with Framer Motion

Building a «hold to confirm» button with Framer Motion

Published at 25 March 2024

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

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.

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: .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: .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.

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.

You may also like: