'Hold to Destroy' animation
While taking Emil's animations.dev course, I got inspired by his "hold to delete" button animation. I really liked the idea of animating a button while holding it, so I wanted to make something special and mine while practicing mimicking the base animation. My twist was to add progressive shake intensity that builds the longer you hold the button, until it finally pops.
How It Works
Unlike Emil's CSS-only approach, I built this with Motion (formerly Framer Motion) to have more control over the animation states.
States
The component has four phases: idle, holding, popping, and destroyed. Each phase maps to a Motion variant that defines how the element should look and animate.
type Phase = "idle" | "holding" | "popping" | "destroyed";
Tracking Progress
When the user starts holding, I use requestAnimationFrame to smoothly update the progress from 0 to 1 over 2 seconds:
const HOLD_DURATION = 2000;
const updateProgress = () => {
const elapsed = Date.now() - holdStartTime.current;
const progress = Math.min(elapsed / HOLD_DURATION, 1);
setHoldProgress(progress);
if (progress >= 1) {
setPhase("popping");
} else {
animationFrame.current = requestAnimationFrame(updateProgress);
}
};
The Shake Animation
The shake uses rotation keyframes that swing left, then right, then back to center: [0, -intensity, +intensity, 0]. This creates a total swing of twice the intensity value.
Shake intensity starts at around 1° and builds to a maximum of 4°. I used Math.round() because I had some issues trying to animate rotation with decimal values. The intensity caps at 4° around 75% progress and stays there until destruction.
Shake speed is constant at 0.15 seconds per cycle, which gives us about 4 shakes per second. I found that keeping the speed constant feels more natural than accelerating it. Going faster than 0.1s causes flickering, slower than 0.3s feels sluggish.
const shakeIntensity = Math.min(Math.round(0.5 + holdProgress * 2.7), 4);
const shakeDuration = 0.15;
const variants = {
idle: {
rotate: 0,
scale: 1,
transition: { type: "spring", stiffness: 200, damping: 30 },
},
holding: {
rotate: [0, -shakeIntensity, shakeIntensity, 0],
scale: 1 - holdProgress * 0.2,
transition: {
rotate: {
repeat: Infinity,
duration: shakeDuration,
ease: "linear",
},
},
},
popping: {
scale: [1, 1.3, 0],
opacity: [1, 1, 0],
transition: { duration: 0.3, ease: "easeOut" },
},
};
The pop is the payoff. When the hold completes, the element scales up to 130% before collapsing to nothing. That brief expansion creates a satisfying burst effect.
Putting It Together
Here's a minimal implementation you can use as a starting point:
import { motion } from "motion/react";
import { useState, useRef, useEffect, useCallback } from "react";
type Phase = "idle" | "holding" | "popping" | "destroyed";
const HOLD_DURATION = 2000;
export function HoldToDestroy({ children, onDestroy }) {
const [phase, setPhase] = useState<Phase>("idle");
const [holdProgress, setHoldProgress] = useState(0);
const holdStartTime = useRef<number | null>(null);
const animationFrame = useRef<number | null>(null);
// Update progress while holding
useEffect(() => {
if (phase !== "holding") return;
const updateProgress = () => {
if (!holdStartTime.current) return;
const elapsed = Date.now() - holdStartTime.current;
const progress = Math.min(elapsed / HOLD_DURATION, 1);
setHoldProgress(progress);
if (progress >= 1) {
setPhase("popping");
} else {
animationFrame.current = requestAnimationFrame(updateProgress);
}
};
animationFrame.current = requestAnimationFrame(updateProgress);
return () => {
if (animationFrame.current) cancelAnimationFrame(animationFrame.current);
};
}, [phase]);
// Trigger destroy callback after pop animation
useEffect(() => {
if (phase !== "popping") return;
const timeout = setTimeout(() => {
setPhase("destroyed");
onDestroy?.();
}, 300);
return () => clearTimeout(timeout);
}, [phase, onDestroy]);
const startHold = useCallback(() => {
if (phase !== "idle") return;
holdStartTime.current = Date.now();
setPhase("holding");
}, [phase]);
const endHold = useCallback(() => {
if (phase !== "holding") return;
setPhase("idle");
holdStartTime.current = null;
if (animationFrame.current) cancelAnimationFrame(animationFrame.current);
setHoldProgress(0);
}, [phase]);
if (phase === "destroyed") return null;
// Progressive shake intensity
const shakeIntensity = Math.min(Math.round(0.5 + holdProgress * 2.7), 4);
const variants = {
idle: {
rotate: 0,
scale: 1,
transition: { type: "spring", stiffness: 200, damping: 30 },
},
holding: {
rotate: [0, -shakeIntensity, shakeIntensity, 0],
scale: 1 - holdProgress * 0.2,
transition: {
rotate: { repeat: Infinity, duration: 0.15, ease: "linear" },
},
},
popping: {
scale: [1, 1.3, 0],
opacity: [1, 1, 0],
transition: { duration: 0.3, ease: "easeOut" },
},
};
const currentVariant =
phase === "popping" ? "popping" : phase === "holding" ? "holding" : "idle";
return (
<motion.button
variants={variants}
animate={currentVariant}
onPointerDown={startHold}
onPointerUp={endHold}
onPointerLeave={endHold}
style={{ cursor: phase === "holding" ? "grabbing" : "grab" }}
>
{children}
</motion.button>
);
}