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

Hold to destroy

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>
  );
}