Randy Marsh

Jelly Button

Buttons 1

About

This is an animated jelly button that squishes and pops out on click, click and hold, or drag. Originally created by https://x.com/cerpow and adapted for clickability as a UI component by https://x.com/nondualrandy

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Jelly Button to your project and fits it to your theme.

Code

How Jelly Button is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import { useRef, useEffect, useState, useCallback } from "react";
import { cn } from "@/lib/utils";
import gsap from "gsap";

// Made by Voicu Apostol - www.cerpow.com
// Adapted for React by Randy

interface JellyButtonProps {
  className?: string;
  totalFrames?: number;
  startFrame?: number;
  squishTarget?: number;
  imageBaseUrl?: string;
}

export default function JellyButton({
  className,
  totalFrames = 215,
  startFrame = 70,
  squishTarget = 180,
  imageBaseUrl = "https://cerpow.github.io/cerpow-img/jelly/jelly_",
}: JellyButtonProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const wrapperRef = useRef<HTMLDivElement>(null);
  const imagesRef = useRef<HTMLImageElement[]>([]);
  const currentFrameRef = useRef(-1);
  const displayFrameRef = useRef(startFrame);
  const animationPhaseRef = useRef<'idle' | 'squishing' | 'holding' | 'dragging' | 'expanding' | 'settling' | 'releasing'>('idle');
  
  const [isLoading, setIsLoading] = useState(true);
  const [isReady, setIsReady] = useState(false);

  // Interaction state refs
  const isPressedRef = useRef(false);
  const isDraggingRef = useRef(false);
  const pressStartTimeRef = useRef(0);
  const pressStartYRef = useRef(0);
  const pressStartFrameRef = useRef(0);
  const squishTweenRef = useRef<gsap.core.Tween | null>(null);
  const releaseTweenRef = useRef<gsap.core.Tween | null>(null);

  const quickClickThreshold = 200;
  const dragThreshold = 10;
  const dragSensitivity = 0.8;

  // Draw frame
  const drawFrame = useCallback((frame: number) => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const frameIndex = Math.max(0, Math.min(totalFrames - 1, Math.floor(frame)));
    const img = imagesRef.current[frameIndex];
    
    if (frameIndex !== currentFrameRef.current && img?.complete) {
      ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
      ctx.drawImage(img, 0, 0, canvas.clientWidth, canvas.clientHeight);
      currentFrameRef.current = frameIndex;
    }
  }, [totalFrames]);

  // Animation loop
  const animate = useCallback(() => {
    drawFrame(displayFrameRef.current);
    requestAnimationFrame(animate);
  }, [drawFrame]);

  // Set canvas size
  const setCanvasSize = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const ratio = window.devicePixelRatio || 1;
    const width = canvas.clientWidth;
    const height = width * (3 / 4);
    canvas.width = width * ratio;
    canvas.height = height * ratio;
    canvas.style.height = `${height}px`;
    ctx.scale(ratio, ratio);
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = "medium";
    currentFrameRef.current = -1;
  }, []);

  // Pointer handlers
  const handlePointerDown = useCallback((e: React.PointerEvent) => {
    if (isPressedRef.current) return;
    isPressedRef.current = true;
    isDraggingRef.current = false;
    pressStartTimeRef.current = Date.now();
    pressStartYRef.current = e.clientY;
    pressStartFrameRef.current = displayFrameRef.current;

    // Kill existing animations
    if (releaseTweenRef.current) {
      releaseTweenRef.current.kill();
      releaseTweenRef.current = null;
    }
    if (squishTweenRef.current) {
      squishTweenRef.current.kill();
      squishTweenRef.current = null;
    }

    animationPhaseRef.current = 'squishing';

    squishTweenRef.current = gsap.to({ frame: displayFrameRef.current }, {
      frame: squishTarget,
      duration: 0.1,
      ease: "power2.in",
      onUpdate: function() {
        if (!isDraggingRef.current) {
          displayFrameRef.current = this.targets()[0].frame;
        }
      },
      onComplete: () => {
        if (!isDraggingRef.current) {
          animationPhaseRef.current = 'holding';
        }
      }
    });
  }, [squishTarget]);

  const handlePointerMove = useCallback((e: React.PointerEvent) => {
    if (!isPressedRef.current) return;

    const deltaY = e.clientY - pressStartYRef.current;

    if (!isDraggingRef.current && Math.abs(deltaY) > dragThreshold) {
      isDraggingRef.current = true;
      animationPhaseRef.current = 'dragging';

      if (squishTweenRef.current) {
        squishTweenRef.current.kill();
        squishTweenRef.current = null;
      }
    }

    if (isDraggingRef.current) {
      const frameOffset = deltaY * dragSensitivity;
      let newFrame = pressStartFrameRef.current + frameOffset;
      newFrame = Math.max(0, Math.min(totalFrames - 1, newFrame));
      displayFrameRef.current = newFrame;
    }
  }, [totalFrames]);

  const handlePointerUp = useCallback(() => {
    if (!isPressedRef.current) return;
    isPressedRef.current = false;

    const pressDuration = Date.now() - pressStartTimeRef.current;
    const wasQuickClick = pressDuration < quickClickThreshold && !isDraggingRef.current;

    if (squishTweenRef.current) {
      squishTweenRef.current.kill();
      squishTweenRef.current = null;
    }

    if (wasQuickClick) {
      animationPhaseRef.current = 'squishing';

      squishTweenRef.current = gsap.to({ frame: displayFrameRef.current }, {
        frame: squishTarget,
        duration: 0,
        ease: "power2.in",
        onUpdate: function() {
          displayFrameRef.current = this.targets()[0].frame;
        },
        onComplete: () => {
          animationPhaseRef.current = 'expanding';
          releaseTweenRef.current = gsap.to({ frame: squishTarget }, {
            frame: 0,
            duration: 0.45,
            ease: "power2.out",
            onUpdate: function() {
              displayFrameRef.current = this.targets()[0].frame;
            },
            onComplete: () => {
              animationPhaseRef.current = 'settling';
              gsap.to({ frame: 0 }, {
                frame: startFrame,
                duration: 0.2,
                ease: "power1.inOut",
                onUpdate: function() {
                  displayFrameRef.current = this.targets()[0].frame;
                },
                onComplete: () => {
                  releaseTweenRef.current = null;
                  animationPhaseRef.current = 'idle';
                  displayFrameRef.current = startFrame;
                }
              });
            }
          });
        }
      });
    } else {
      animationPhaseRef.current = 'releasing';

      const distance = Math.abs(displayFrameRef.current - startFrame);
      const releaseDuration = Math.max(0.3, Math.min(1.2, distance / 100));

      releaseTweenRef.current = gsap.to({ frame: displayFrameRef.current }, {
        frame: startFrame,
        duration: releaseDuration,
        ease: "power2.out",
        onUpdate: function() {
          displayFrameRef.current = this.targets()[0].frame;
        },
        onComplete: () => {
          releaseTweenRef.current = null;
          animationPhaseRef.current = 'idle';
          displayFrameRef.current = startFrame;
          isDraggingRef.current = false;
        }
      });
    }

    isDraggingRef.current = false;
  }, [squishTarget, startFrame]);

  // Preload images and initialize
  useEffect(() => {
    let loadedImages = 0;
    let failedImages = 0;
    const images: HTMLImageElement[] = [];

    const checkComplete = () => {
      if (loadedImages + failedImages === totalFrames) {
        imagesRef.current = images;
        setIsLoading(false);
        
        setTimeout(() => {
          displayFrameRef.current = startFrame;
          setIsReady(true);
          animate();
        }, 350);
      }
    };

    for (let i = 0; i < totalFrames; i++) {
      const img = new Image();
      img.crossOrigin = "anonymous";
      img.src = `${imageBaseUrl}${i.toString().padStart(5, "0")}.jpg`;
      img.onload = () => {
        loadedImages++;
        checkComplete();
      };
      img.onerror = () => {
        failedImages++;
        checkComplete();
      };
      images[i] = img;
    }

    return () => {
      // Cleanup tweens on unmount
      if (squishTweenRef.current) squishTweenRef.current.kill();
      if (releaseTweenRef.current) releaseTweenRef.current.kill();
    };
  }, [totalFrames, startFrame, imageBaseUrl, animate]);

  // Handle resize
  useEffect(() => {
    setCanvasSize();
    window.addEventListener("resize", setCanvasSize);
    return () => window.removeEventListener("resize", setCanvasSize);
  }, [setCanvasSize]);

  return (
    <div
      ref={wrapperRef}
      className={cn(
        "relative w-full max-w-[640px] aspect-[4/3] flex items-center justify-center cursor-pointer select-none",
        "translate-x-[-1%] translate-y-[3%]",
        className
      )}
      style={{ touchAction: "none" }}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerLeave={handlePointerUp}
      onPointerCancel={handlePointerUp}
    >
      <canvas
        ref={canvasRef}
        width={1280}
        height={960}
        className={cn(
          "w-full h-full select-none z-10 rounded-[clamp(22px,6vw,42px)]",
          "transition-all duration-1000 ease-out",
          isReady ? "opacity-100 scale-100" : "opacity-0 scale-[0.92]"
        )}
      />
      
      {/* Loader */}
      <div
        className={cn(
          "absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
          "w-[90%] max-w-[150px] h-0.5 bg-white/20",
          "transition-opacity duration-500",
          !isLoading && "opacity-0 invisible"
        )}
      >
        <span 
          className="flex bg-white h-full w-1/4"
          style={{
            animation: "jellyLoader 1.3s infinite alternate ease-in-out"
          }}
        />
      </div>
    </div>
  );
}

export { JellyButton };
export type { JellyButtonProps };

// ============================================================================
// DEMO COMPONENT FOR 21ST.DEV
// Fully self-contained - no external imports
// ============================================================================

function Component() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const wrapperRef = useRef<HTMLDivElement>(null);
  const imagesRef = useRef<HTMLImageElement[]>([]);
  const currentFrameRef = useRef(-1);
  const displayFrameRef = useRef(70);
  const animationPhaseRef = useRef<string>('idle');
  
  const [isLoading, setIsLoading] = useState(true);
  const [isReady, setIsReady] = useState(false);

  const isPressedRef = useRef(false);
  const isDraggingRef = useRef(false);
  const pressStartTimeRef = useRef(0);
  const pressStartYRef = useRef(0);
  const pressStartFrameRef = useRef(0);
  const squishTweenRef = useRef<ReturnType<typeof gsap.to> | null>(null);
  const releaseTweenRef = useRef<ReturnType<typeof gsap.to> | null>(null);

  const totalFrames = 215;
  const startFrame = 70;
  const squishTarget = 180;
  const imageBaseUrl = "https://cerpow.github.io/cerpow-img/jelly/jelly_";
  const quickClickThreshold = 200;
  const dragThreshold = 10;
  const dragSensitivity = 0.8;

  const drawFrame = useCallback((frame: number) => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const frameIndex = Math.max(0, Math.min(totalFrames - 1, Math.floor(frame)));
    const img = imagesRef.current[frameIndex];
    if (frameIndex !== currentFrameRef.current && img?.complete) {
      ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
      ctx.drawImage(img, 0, 0, canvas.clientWidth, canvas.clientHeight);
      currentFrameRef.current = frameIndex;
    }
  }, []);

  const animate = useCallback(() => {
    drawFrame(displayFrameRef.current);
    requestAnimationFrame(animate);
  }, [drawFrame]);

  const setCanvasSize = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const ratio = window.devicePixelRatio || 1;
    const width = canvas.clientWidth;
    const height = width * (3 / 4);
    canvas.width = width * ratio;
    canvas.height = height * ratio;
    canvas.style.height = `${height}px`;
    ctx.scale(ratio, ratio);
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = "medium";
    currentFrameRef.current = -1;
  }, []);

  const handlePointerDown = useCallback((e: React.PointerEvent) => {
    if (isPressedRef.current) return;
    isPressedRef.current = true;
    isDraggingRef.current = false;
    pressStartTimeRef.current = Date.now();
    pressStartYRef.current = e.clientY;
    pressStartFrameRef.current = displayFrameRef.current;

    if (releaseTweenRef.current) { releaseTweenRef.current.kill(); releaseTweenRef.current = null; }
    if (squishTweenRef.current) { squishTweenRef.current.kill(); squishTweenRef.current = null; }

    animationPhaseRef.current = 'squishing';
    squishTweenRef.current = gsap.to({ frame: displayFrameRef.current }, {
      frame: squishTarget,
      duration: 0.1,
      ease: "power2.in",
      onUpdate: function() { if (!isDraggingRef.current) displayFrameRef.current = this.targets()[0].frame; },
      onComplete: () => { if (!isDraggingRef.current) animationPhaseRef.current = 'holding'; }
    });
  }, []);

  const handlePointerMove = useCallback((e: React.PointerEvent) => {
    if (!isPressedRef.current) return;
    const deltaY = e.clientY - pressStartYRef.current;
    if (!isDraggingRef.current && Math.abs(deltaY) > dragThreshold) {
      isDraggingRef.current = true;
      animationPhaseRef.current = 'dragging';
      if (squishTweenRef.current) { squishTweenRef.current.kill(); squishTweenRef.current = null; }
    }
    if (isDraggingRef.current) {
      const frameOffset = deltaY * dragSensitivity;
      let newFrame = pressStartFrameRef.current + frameOffset;
      newFrame = Math.max(0, Math.min(totalFrames - 1, newFrame));
      displayFrameRef.current = newFrame;
    }
  }, []);

  const handlePointerUp = useCallback(() => {
    if (!isPressedRef.current) return;
    isPressedRef.current = false;
    const pressDuration = Date.now() - pressStartTimeRef.current;
    const wasQuickClick = pressDuration < quickClickThreshold && !isDraggingRef.current;
    if (squishTweenRef.current) { squishTweenRef.current.kill(); squishTweenRef.current = null; }

    if (wasQuickClick) {
      animationPhaseRef.current = 'squishing';
      squishTweenRef.current = gsap.to({ frame: displayFrameRef.current }, {
        frame: squishTarget, duration: 0, ease: "power2.in",
        onUpdate: function() { displayFrameRef.current = this.targets()[0].frame; },
        onComplete: () => {
          animationPhaseRef.current = 'expanding';
          releaseTweenRef.current = gsap.to({ frame: squishTarget }, {
            frame: 0, duration: 0.45, ease: "power2.out",
            onUpdate: function() { displayFrameRef.current = this.targets()[0].frame; },
            onComplete: () => {
              animationPhaseRef.current = 'settling';
              gsap.to({ frame: 0 }, {
                frame: startFrame, duration: 0.2, ease: "power1.inOut",
                onUpdate: function() { displayFrameRef.current = this.targets()[0].frame; },
                onComplete: () => { releaseTweenRef.current = null; animationPhaseRef.current = 'idle'; displayFrameRef.current = startFrame; }
              });
            }
          });
        }
      });
    } else {
      animationPhaseRef.current = 'releasing';
      const distance = Math.abs(displayFrameRef.current - startFrame);
      const releaseDuration = Math.max(0.3, Math.min(1.2, distance / 100));
      releaseTweenRef.current = gsap.to({ frame: displayFrameRef.current }, {
        frame: startFrame, duration: releaseDuration, ease: "power2.out",
        onUpdate: function() { displayFrameRef.current = this.targets()[0].frame; },
        onComplete: () => { releaseTweenRef.current = null; animationPhaseRef.current = 'idle'; displayFrameRef.current = startFrame; isDraggingRef.current = false; }
      });
    }
    isDraggingRef.current = false;
  }, []);

  useEffect(() => {
    let loadedImages = 0;
    let failedImages = 0;
    const images: HTMLImageElement[] = [];
    const checkComplete = () => {
      if (loadedImages + failedImages === totalFrames) {
        imagesRef.current = images;
        setIsLoading(false);
        setTimeout(() => { displayFrameRef.current = startFrame; setIsReady(true); animate(); }, 350);
      }
    };
    for (let i = 0; i < totalFrames; i++) {
      const img = new Image();
      img.crossOrigin = "anonymous";
      img.src = `${imageBaseUrl}${i.toString().padStart(5, "0")}.jpg`;
      img.onload = () => { loadedImages++; checkComplete(); };
      img.onerror = () => { failedImages++; checkComplete(); };
      images[i] = img;
    }
    return () => {
      if (squishTweenRef.current) squishTweenRef.current.kill();
      if (releaseTweenRef.current) releaseTweenRef.current.kill();
    };
  }, [animate]);

  useEffect(() => {
    setCanvasSize();
    window.addEventListener("resize", setCanvasSize);
    return () => window.removeEventListener("resize", setCanvasSize);
  }, [setCanvasSize]);

  return (
    <>
      <style>{`
        @keyframes jellyLoader {
          0% { opacity: 0; transform: translateX(0%); }
          50% { opacity: 1; }
          100% { opacity: 0; transform: translateX(300%); }
        }
      `}</style>
      <div
        ref={wrapperRef}
        style={{
          position: "relative",
          width: "100%",
          maxWidth: 640,
          aspectRatio: "4/3",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          cursor: "pointer",
          userSelect: "none",
          touchAction: "none",
          transform: "translateX(-1%) translateY(3%)",
        }}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onPointerLeave={handlePointerUp}
        onPointerCancel={handlePointerUp}
      >
        <canvas
          ref={canvasRef}
          width={1280}
          height={960}
          style={{
            width: "100%",
            height: "100%",
            userSelect: "none",
            zIndex: 10,
            borderRadius: "clamp(22px, 6vw, 42px)",
            transition: "all 1000ms ease-out",
            opacity: isReady ? 1 : 0,
            transform: isReady ? "scale(1)" : "scale(0.92)",
          }}
        />
        <div
          style={{
            position: "absolute",
            left: "50%",
            top: "50%",
            transform: "translateX(-50%) translateY(-50%)",
            width: "90%",
            maxWidth: 150,
            height: 2,
            backgroundColor: "rgba(255,255,255,0.2)",
            transition: "opacity 500ms",
            opacity: isLoading ? 1 : 0,
            visibility: isLoading ? "visible" : "hidden",
          }}
        >
          <span
            style={{
              display: "flex",
              backgroundColor: "white",
              height: "100%",
              width: "25%",
              animation: "jellyLoader 1.3s infinite alternate ease-in-out",
            }}
          />
        </div>
      </div>
    </>
  );
}

export { Component };

Dependencies

More by Randy Marsh

View all
  • Twitter Testimonial Cards

    Twitter Testimonial Cards

    760
  • 3D Shader Card

    3D Shader Card

  • Neumorphic Button

    Neumorphic Button

More in Buttons

View all
  • Button

    Button

    14
  • Button

    Button

    34
  • Texture Button

    Texture Button

    92
  • Button Save

    Button Save

    46
  • Button

    Button

    64
  • Button Group

    Button Group

    3
  • App Store Button

    App Store Button

    454
  • Notification Button

    Notification Button

    103