Created
August 19, 2025 08:38
-
-
Save thesabbir/eed2184a941aaae56fa3abe23c78988f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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