Skip to content

Instantly share code, notes, and snippets.

@thesabbir
Created August 19, 2025 08:38
Show Gist options
  • Save thesabbir/eed2184a941aaae56fa3abe23c78988f to your computer and use it in GitHub Desktop.
Save thesabbir/eed2184a941aaae56fa3abe23c78988f to your computer and use it in GitHub Desktop.
import React, { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence, useAnimation } from "framer-motion";
import { Download } from "lucide-react";
import paper from "paper";
/**
* Oceanic wave-inspired Paper.js background with textured waves + Framer Motion draggable button.
* Additions in this revision:
* - Flying Arrow Effect (icon drops then snaps up on click; gentle idle bob)
* - Splash Effect (local droplet burst around the button)
* - Gradient Shift (slow hue rotation of button's layered gradients)
* - Shadow Pulse (shadow intensifies on hover/tap)
* - Glass Shine Effect (diagonal sweep on hover)
* - "Download into water" splash when dragged to the bottom edge
*/
export default function DownloadCVPage({ cvUrl = "#" }: { cvUrl?: string }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const scopeRef = useRef<paper.PaperScope | null>(null);
const [ripple, setRipple] = useState<{ x: number; y: number; id: number } | null>(null);
const [trail, setTrail] = useState<{ x: number; y: number; id: number }[]>([]);
const [splashes, setSplashes] = useState<
{ x: number; y: number; id: number; size: number; life: number }[]
>([]);
const rippleIdRef = useRef(0);
const waveImpactRef = useRef<{ time: number; strength: number; origin: { x: number; y: number } } | null>(null);
const controls = useAnimation();
const iconControls = useAnimation();
useEffect(() => {
if (!canvasRef.current) return;
const scope = new paper.PaperScope();
scopeRef.current = scope;
scope.setup(canvasRef.current);
const view = scope.view;
const { Point, Path } = scope;
let waveShapes: paper.Path[] = [];
let mouse = view.center.clone();
const params = {
waves: 24,
amplitude: 50,
wavelength: 250,
speed: 0.6,
baseColors: [
new paper.Color(0.05, 0.15, 0.4, 0.6),
new paper.Color(0.1, 0.3, 0.6, 0.5),
new paper.Color(0.2, 0.5, 0.7, 0.4),
],
highlight: new paper.Color(0.2, 0.7, 1, 0.8),
};
function initWaves() {
waveShapes.forEach((p) => p.remove());
waveShapes = [];
for (let i = 0; i < params.waves; i++) {
const path = new Path({
fillColor: params.baseColors[i % params.baseColors.length],
opacity: 0.5,
closed: true,
});
waveShapes.push(path);
}
}
function lerpColor(c1: paper.Color, c2: paper.Color, t: number) {
return new paper.Color(
c1.red + (c2.red - c1.red) * t,
c1.green + (c2.green - c1.green) * t,
c1.blue + (c2.blue - c1.blue) * t,
c1.alpha + (c2.alpha - c1.alpha) * t
);
}
function updateWaves(t: number) {
const W = view.size.width;
const H = view.size.height;
const impact = waveImpactRef.current;
for (let i = 0; i < params.waves; i++) {
const path = waveShapes[i];
path.removeSegments();
const offsetY = (H / params.waves) * i + H / (params.waves * 2);
const amp = params.amplitude * (1 - i / params.waves * 0.6);
path.add(new Point(0, H));
for (let x = 0; x <= W; x += 12) {
let y = offsetY +
Math.sin((x / params.wavelength) * Math.PI * 2 + t * params.speed + i) * amp +
Math.cos((x / 80) + t * 0.8) * 8;
y += Math.sin((x / params.wavelength) * Math.PI * 4 + t * params.speed) * (amp * 0.25);
const distMouse = Math.abs(x - mouse.x) + Math.abs(y - mouse.y);
if (distMouse < 200) {
y += Math.sin(t * 4 + distMouse * 0.05) * (200 - distMouse) * 0.05;
}
if (impact) {
const impactAge = t - impact.time;
if (impactAge < 3) {
const dx = x - impact.origin.x;
const dy = y - impact.origin.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const wave = Math.sin(dist * 0.08 - impactAge * 6) * impact.strength * Math.exp(-impactAge * 1.5);
y += wave;
}
}
path.add(new Point(x, y));
}
path.add(new Point(W, H));
path.closePath();
const influence = Math.max(0, 1 - mouse.getDistance(new Point(W / 2, offsetY)) / 400);
path.fillColor = lerpColor(params.baseColors[i % params.baseColors.length], params.highlight, influence);
}
}
initWaves();
const onFrame = (event: paper.IFrameEvent) => {
updateWaves(event.time);
};
view.onFrame = onFrame;
const onPointerMove = (e: PointerEvent) => {
const rect = (view.element as HTMLCanvasElement).getBoundingClientRect();
mouse = new Point(e.clientX - rect.left, e.clientY - rect.top);
};
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("resize", initWaves);
return () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("resize", initWaves);
view.onFrame = null as any;
scope.project.clear();
scope.remove();
};
}, []);
const triggerLocalSplash = (x: number, y: number, strength = 1) => {
// droplet burst around (x, y)
const idBase = Date.now();
const droplets = Array.from({ length: 10 + Math.floor(8 * strength) }).map((_, i) => ({
x,
y,
id: idBase + i,
size: Math.random() * 6 + 3,
life: 0,
}));
setSplashes((prev) => [...prev, ...droplets]);
// clean after animation
setTimeout(() => {
setSplashes((prev) => prev.filter((d) => d.id < idBase || d.id >= idBase + droplets.length ? true : false));
}, 900);
};
const triggerRipple = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
rippleIdRef.current += 1;
setRipple({ x: e.clientX, y: e.clientY, id: rippleIdRef.current });
const rect = (canvasRef.current as HTMLCanvasElement).getBoundingClientRect();
waveImpactRef.current = {
time: performance.now() / 1000,
strength: 25,
origin: { x: e.clientX - rect.left, y: e.clientY - rect.top },
};
// local splash right where the button is
triggerLocalSplash(e.clientX, e.clientY, 1);
// flying arrow motion
iconControls.start({ y: [0, 10, -14, 0], transition: { duration: 0.6, times: [0, 0.3, 0.7, 1], ease: "easeInOut" } });
setTimeout(() => setRipple(null), 650);
};
const handleDrag = (event: any, info: any) => {
const rect = (canvasRef.current as HTMLCanvasElement).getBoundingClientRect();
const buttonRect = (event.target as HTMLElement).getBoundingClientRect();
const centerX = buttonRect.left + buttonRect.width / 2 - rect.left;
const centerY = buttonRect.top + buttonRect.height / 2 - rect.top;
setTrail((prev) => [
...prev.slice(-8),
{ x: centerX, y: centerY, id: Date.now() },
]);
};
const handleDragEnd = (event?: any) => {
// If dropped near bottom, trigger a stronger splash into water
try {
const rect = (canvasRef.current as HTMLCanvasElement).getBoundingClientRect();
const buttonRect = (event?.target as HTMLElement)?.getBoundingClientRect?.();
if (buttonRect) {
const centerX = buttonRect.left + buttonRect.width / 2;
const centerY = buttonRect.top + buttonRect.height / 2;
const bottomThreshold = rect.bottom - 80; // 80px from bottom
if (centerY > bottomThreshold) {
// visual local splash
triggerLocalSplash(centerX, rect.bottom - 16, 2);
// drive waves
waveImpactRef.current = {
time: performance.now() / 1000,
strength: 40,
origin: { x: centerX - rect.left, y: rect.height - 10 },
};
}
}
} catch (_e) {}
// Return the button to center within 4 seconds
controls.start({ x: 0, y: 0, transition: { duration: 4, ease: "easeInOut" } });
};
return (
<div className="relative h-screen w-screen overflow-hidden bg-black text-white">
{/* Paper.js Canvas Background */}
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
{/* Draggable Button with Effects */}
<div className="relative z-10 flex h-full w-full items-center justify-center p-6">
<motion.div
drag
dragMomentum={false}
dragElastic={0.2}
onDrag={handleDrag}
onDragEnd={handleDragEnd}
dragTransition={{ bounceStiffness: 50, bounceDamping: 20 }}
className="relative"
animate={controls}
>
{/* Gradient Trail Effect */}
{trail.map((p) => (
<motion.span
key={p.id}
className="pointer-events-none absolute h-5 w-5 rounded-full blur-md"
style={{
left: p.x - 10,
top: p.y - 10,
background:
"radial-gradient(circle at center, rgba(25,80,160,0.6), rgba(59,130,246,0.4), rgba(32,150,243,0.2), transparent)",
}}
initial={{ opacity: 0.6, scale: 0.6 }}
animate={{ opacity: 0, scale: 1.3 }}
transition={{ duration: 1.2, ease: "easeOut" }}
/>
))}
{/* Local Splash Droplets */}
<AnimatePresence>
{splashes.map((d) => (
<motion.span
key={d.id}
className="pointer-events-none absolute rounded-full"
style={{ left: d.x - 4, top: d.y - 4, width: d.size, height: d.size, background: "rgba(160,220,255,0.7)" }}
initial={{ opacity: 0.8, x: 0, y: 0, scale: 1 }}
animate={{ opacity: 0, x: (Math.random() - 0.5) * 120, y: -60 - Math.random() * 40, scale: 0.6 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.9, ease: "easeOut" }}
/>
))}
</AnimatePresence>
{/* Button */}
<motion.button
onClick={(e) => {
triggerRipple(e);
if (cvUrl && cvUrl !== "#") {
const link = document.createElement("a");
link.href = cvUrl;
link.download = "CV";
document.body.appendChild(link);
link.click();
link.remove();
}
}}
whileHover={{ scale: 1.05, y: -2, boxShadow: "0 18px 40px rgba(0,0,0,0.6), inset 0 0 0 1px rgba(255,255,255,0.18), inset 0 -1px 30px rgba(59,130,246,0.2)" }}
whileTap={{ scale: 0.98, rotate: [0, -10, 10, 0] }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 220, damping: 20 }}
className="group relative inline-flex items-center gap-3 overflow-hidden rounded-2xl px-7 py-4 text-lg font-semibold tracking-wide"
style={{
background:
"radial-gradient(120px 80px at 20% 20%, rgba(255,255,255,0.12), rgba(255,255,255,0) 60%)," +
"radial-gradient(120px 80px at 80% 30%, rgba(59,130,246,0.25), rgba(255,255,255,0) 60%)," +
"radial-gradient(120px 80px at 20% 80%, rgba(32,150,243,0.25), rgba(255,255,255,0) 60%)," +
"linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02))",
boxShadow:
"0 10px 30px rgba(0,0,0,0.45), inset 0 0 0 1px rgba(255,255,255,0.15), inset 0 -1px 20px rgba(59,130,246,0.15)",
}}
>
{/* Gradient Shift (hue rotate) overlay */}
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0"
animate={{ filter: ["hue-rotate(0deg)", "hue-rotate(60deg)", "hue-rotate(0deg)"] }}
transition={{ duration: 12, repeat: Infinity, ease: "linear" }}
style={{ background: "transparent" }}
/>
{/* Glass Shine Effect */}
<span
aria-hidden
className="pointer-events-none absolute -top-1/2 left-[-150%] h-[200%] w-[60%] -rotate-12 bg-gradient-to-r from-transparent via-white/25 to-transparent opacity-0 transition-all duration-700 ease-out group-hover:translate-x-[300%] group-hover:opacity-100"
/>
{/* Icon with Flying Arrow Effect (idle bob + click drop) */}
<motion.span
className="relative inline-flex h-9 w-9 items-center justify-center rounded-xl bg-white/10"
initial={{ rotate: 0, scale: 1, y: 0 }}
animate={iconControls}
>
<motion.span
initial={{ y: 0 }}
animate={{ y: [0, -4, 0], rotate: [0, -6, 6, 0], scale: [1, 1.06, 1] }}
transition={{ repeat: Infinity, duration: 4, ease: "easeInOut" }}
className="inline-flex"
>
<Download className="h-5 w-5" />
</motion.span>
</motion.span>
<span className="select-none">Download CV</span>
</motion.button>
</motion.div>
</div>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment