Spaces:
Running
Running
| "use client"; | |
| import { useEffect, useRef, useState, useCallback } from "react"; | |
| import { motion, useScroll, useTransform, useMotionValueEvent } from "framer-motion"; | |
| const TOTAL_FRAMES = 240; | |
| const IMAGE_PATH = "/scroll_animation_image_frames/ezgif-frame-"; | |
| function useImagePreloader(totalFrames: number) { | |
| const [images, setImages] = useState<HTMLImageElement[]>([]); | |
| const [loadProgress, setLoadProgress] = useState(0); | |
| const [isLoaded, setIsLoaded] = useState(false); | |
| useEffect(() => { | |
| const loadedImages: HTMLImageElement[] = []; | |
| let loadedCount = 0; | |
| for (let i = 1; i <= totalFrames; i++) { | |
| const img = new Image(); | |
| const frameNumber = i.toString().padStart(3, "0"); | |
| img.src = `${IMAGE_PATH}${frameNumber}.jpg`; | |
| img.onload = () => { | |
| loadedCount++; | |
| setLoadProgress(Math.round((loadedCount / totalFrames) * 100)); | |
| if (loadedCount === totalFrames) { | |
| setImages(loadedImages); | |
| setTimeout(() => setIsLoaded(true), 400); | |
| } | |
| }; | |
| img.onerror = () => { | |
| loadedCount++; | |
| setLoadProgress(Math.round((loadedCount / totalFrames) * 100)); | |
| }; | |
| loadedImages.push(img); | |
| } | |
| }, [totalFrames]); | |
| return { images, loadProgress, isLoaded }; | |
| } | |
| function LoadingScreen({ progress }: { progress: number }) { | |
| return ( | |
| <motion.div | |
| className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-[#050505]" | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.6 }} | |
| > | |
| <span className="text-8xl md:text-9xl font-thin tracking-tighter text-white/90 tabular-nums"> | |
| {progress}<span className="text-3xl text-white/40">%</span> | |
| </span> | |
| <div className="w-48 h-[2px] bg-white/10 mt-8 rounded-full overflow-hidden"> | |
| <motion.div | |
| className="h-full bg-gradient-to-r from-white/40 to-white/80 rounded-full" | |
| animate={{ width: `${progress}%` }} | |
| /> | |
| </div> | |
| </motion.div> | |
| ); | |
| } | |
| export default function PortfolioScroll() { | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const canvasRef = useRef<HTMLCanvasElement>(null); | |
| const { images, loadProgress, isLoaded } = useImagePreloader(TOTAL_FRAMES); | |
| const { scrollYProgress } = useScroll({ | |
| target: containerRef, | |
| offset: ["start start", "end end"], | |
| }); | |
| // Lenis already poth scrolling — no useSpring needed (avoids double-smoothing) | |
| const frameIndex = useTransform(scrollYProgress, [0, 1], [0, TOTAL_FRAMES - 1]); | |
| // Text opacities - simple bottom-positioned captions | |
| const introOpacity = useTransform(scrollYProgress, [0, 0.12, 0.18, 0.22], [1, 1, 0, 0]); | |
| const craftOpacity = useTransform(scrollYProgress, [0.28, 0.32, 0.48, 0.52], [0, 1, 1, 0]); | |
| const skillsOpacity = useTransform(scrollYProgress, [0.55, 0.58, 0.72, 0.75], [0, 1, 1, 0]); | |
| const ctaOpacity = useTransform(scrollYProgress, [0.85, 0.90, 1, 1], [0, 1, 1, 1]); | |
| const stickyRef = useRef<HTMLDivElement>(null); | |
| const renderFrame = useCallback( | |
| (index: number) => { | |
| const canvas = canvasRef.current; | |
| if (!canvas || images.length === 0) return; | |
| const ctx = canvas.getContext("2d"); | |
| if (!ctx) return; | |
| const frameIdx = Math.round(Math.max(0, Math.min(index, images.length - 1))); | |
| const img = images[frameIdx]; | |
| if (img && img.complete && img.naturalWidth > 0) { | |
| // Use the canvas's actual CSS-rendered size — this automatically adapts | |
| // to any viewport (mobile, desktop, DevTools emulation) because CSS | |
| // classes (w-full h-full) control the display size | |
| const rect = canvas.getBoundingClientRect(); | |
| const vw = rect.width; | |
| const vh = rect.height; | |
| const dpr = window.devicePixelRatio || 1; | |
| // Set canvas buffer size to match rendered size × DPR for crisp pixels | |
| // Do NOT set canvas.style.width/height — let CSS handle display sizing | |
| canvas.width = vw * dpr; | |
| canvas.height = vh * dpr; | |
| // Scale context for high-DPI | |
| ctx.scale(dpr, dpr); | |
| // Enable high-quality image rendering | |
| ctx.imageSmoothingEnabled = true; | |
| ctx.imageSmoothingQuality = 'high'; | |
| const imgAspect = img.naturalWidth / img.naturalHeight; | |
| const viewAspect = vw / vh; | |
| let drawW, drawH, drawX, drawY; | |
| if (viewAspect > imgAspect) { | |
| drawW = vw; | |
| drawH = vw / imgAspect; | |
| drawX = 0; | |
| drawY = (vh - drawH) / 2; | |
| } else { | |
| drawH = vh; | |
| drawW = vh * imgAspect; | |
| drawX = (vw - drawW) / 2; | |
| drawY = 0; | |
| } | |
| ctx.fillStyle = "#050505"; | |
| ctx.fillRect(0, 0, vw, vh); | |
| ctx.drawImage(img, drawX, drawY, drawW, drawH); | |
| // Reset scale for next frame | |
| ctx.setTransform(1, 0, 0, 1, 0, 0); | |
| } | |
| }, | |
| [images] | |
| ); | |
| useMotionValueEvent(frameIndex, "change", (latest) => { | |
| if (isLoaded) renderFrame(latest); | |
| }); | |
| useEffect(() => { | |
| if (isLoaded && images.length > 0) renderFrame(0); | |
| }, [isLoaded, images, renderFrame]); | |
| // Use ResizeObserver on the sticky container — catches DevTools responsive mode, | |
| // orientation changes, and any CSS-driven size changes that window.resize misses | |
| useEffect(() => { | |
| if (!isLoaded) return; | |
| const target = stickyRef.current; | |
| if (!target) return; | |
| const handleResize = () => renderFrame(frameIndex.get()); | |
| const ro = new ResizeObserver(handleResize); | |
| ro.observe(target); | |
| // Also keep window resize as a fallback | |
| window.addEventListener("resize", handleResize); | |
| return () => { | |
| ro.disconnect(); | |
| window.removeEventListener("resize", handleResize); | |
| }; | |
| }, [frameIndex, isLoaded, renderFrame]); | |
| return ( | |
| <> | |
| {!isLoaded && <LoadingScreen progress={loadProgress} />} | |
| <div ref={containerRef} className="relative" style={{ height: "400vh" }}> | |
| {/* Sticky canvas */} | |
| <div ref={stickyRef} className="sticky top-0 left-0 w-full h-screen"> | |
| <canvas | |
| ref={canvasRef} | |
| className="absolute inset-0 w-full h-full" | |
| style={{ opacity: isLoaded ? 1 : 0, transition: "opacity 0.6s ease" }} | |
| /> | |
| {/* Gradient fade at bottom for text readability */} | |
| <div | |
| className="absolute bottom-0 left-0 right-0 h-80 pointer-events-none" | |
| style={{ background: "linear-gradient(to top, rgba(5,5,5,0.9) 0%, transparent 100%)" }} | |
| /> | |
| {/* Intro Text - Left Side */} | |
| {isLoaded && ( | |
| <motion.div | |
| className="absolute bottom-20 md:bottom-1/4 left-1/2 md:left-auto md:right-auto -translate-x-1/2 md:translate-x-0 md:left-8 lg:left-16 xl:left-24 text-center md:text-left pointer-events-none w-[85vw] md:w-auto md:max-w-lg" | |
| style={{ opacity: introOpacity }} | |
| > | |
| <p className="text-cyan-400/90 text-[10px] md:text-xs uppercase tracking-[0.4em] mb-3 font-light"> | |
| Data Scientist | |
| </p> | |
| <h1 className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-extralight text-white tracking-tighter mb-2 leading-[0.9]"> | |
| Jash | |
| </h1> | |
| <h1 className="text-5xl sm:text-6xl md:text-7xl lg:text-8xl font-extralight text-white/80 tracking-tighter leading-[0.9]"> | |
| Doshi | |
| </h1> | |
| <div className="w-16 h-[1px] bg-gradient-to-r from-cyan-400 to-transparent mt-6 mb-4" /> | |
| <p className="text-white/50 text-sm md:text-base font-light leading-relaxed"> | |
| Turning raw data into<br />actionable intelligence | |
| </p> | |
| </motion.div> | |
| )} | |
| {/* Philosophy Text - Right Side */} | |
| {isLoaded && ( | |
| <motion.div | |
| className="absolute bottom-20 md:bottom-1/4 left-1/2 md:left-auto md:right-auto -translate-x-1/2 md:translate-x-0 md:right-8 lg:right-16 xl:right-24 text-center md:text-right pointer-events-none w-[85vw] md:w-auto md:max-w-md" | |
| style={{ opacity: craftOpacity }} | |
| > | |
| <p className="text-orange-400/80 text-[10px] md:text-xs uppercase tracking-[0.4em] mb-3 font-light"> | |
| Philosophy | |
| </p> | |
| <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extralight text-white tracking-tight mb-2 leading-[1.1]"> | |
| Where Data | |
| </h2> | |
| <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extralight tracking-tight leading-[1.1]"> | |
| <span className="text-cyan-400/90">Meets</span>{" "} | |
| <span className="text-orange-400/90">Intelligence</span> | |
| </h2> | |
| <div className="w-16 h-[1px] bg-gradient-to-l from-orange-400 to-transparent mt-6 mb-4 ml-auto" /> | |
| <p className="text-white/50 text-sm md:text-base font-light leading-relaxed"> | |
| Great models should reveal hidden patterns—<br />clear, accurate, and impactful | |
| </p> | |
| </motion.div> | |
| )} | |
| {/* Skills Text - Left Side */} | |
| {isLoaded && ( | |
| <motion.div | |
| className="absolute bottom-20 md:bottom-1/4 left-1/2 md:left-auto -translate-x-1/2 md:translate-x-0 md:left-8 lg:left-16 xl:left-24 text-center md:text-left pointer-events-none w-[85vw] md:w-auto md:max-w-md" | |
| style={{ opacity: skillsOpacity }} | |
| > | |
| <p className="text-teal-400/80 text-[10px] md:text-xs uppercase tracking-[0.4em] mb-3 font-light"> | |
| Expertise | |
| </p> | |
| <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extralight tracking-tight leading-[1.1]"> | |
| <span className="text-cyan-400/90">Models</span> | |
| </h2> | |
| <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extralight text-white tracking-tight leading-[1.1]"> | |
| Insights | |
| </h2> | |
| <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extralight tracking-tight leading-[1.1]"> | |
| <span className="text-orange-400/90">Impact</span> | |
| </h2> | |
| <div className="w-16 h-[1px] bg-gradient-to-r from-teal-400 to-transparent mt-6 mb-4" /> | |
| <p className="text-white/40 text-sm md:text-base font-light"> | |
| Python · TensorFlow · Pandas | |
| </p> | |
| </motion.div> | |
| )} | |
| {/* CTA Text - Right Side */} | |
| {isLoaded && ( | |
| <motion.div | |
| className="absolute bottom-20 md:bottom-1/4 left-1/2 md:left-auto -translate-x-1/2 md:translate-x-0 md:right-8 lg:right-16 xl:right-24 text-center md:text-right w-[85vw] md:w-auto md:max-w-md" | |
| style={{ opacity: ctaOpacity }} | |
| > | |
| <p className="text-cyan-400/80 text-[10px] md:text-xs uppercase tracking-[0.4em] mb-3 font-light"> | |
| Ready to Collaborate? | |
| </p> | |
| <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extralight text-white tracking-tight mb-1 leading-[1.1]"> | |
| Let's Create | |
| </h2> | |
| <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extralight tracking-tight leading-[1.1]"> | |
| <span className="text-orange-400/90">Something</span> | |
| </h2> | |
| <h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extralight text-white/80 tracking-tight leading-[1.1]"> | |
| Extraordinary | |
| </h2> | |
| <div className="w-16 h-[1px] bg-gradient-to-l from-cyan-400 to-transparent mt-6 mb-5 ml-auto" /> | |
| <a | |
| href="#contact" | |
| className="inline-flex items-center gap-2 text-sm text-white/70 hover:text-white transition-colors duration-300 font-light" | |
| > | |
| Get in touch | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-cyan-400"> | |
| <path d="M5 12h14M12 5l7 7-7 7" /> | |
| </svg> | |
| </a> | |
| </motion.div> | |
| )} | |
| {/* Scroll indicator - only at start */} | |
| {isLoaded && ( | |
| <motion.div | |
| className="absolute bottom-6 left-1/2 -translate-x-1/2" | |
| style={{ opacity: introOpacity }} | |
| > | |
| <motion.div | |
| className="w-5 h-8 rounded-full border border-white/30 flex justify-center pt-1.5" | |
| > | |
| <motion.div | |
| className="w-1 h-1.5 bg-white/60 rounded-full" | |
| animate={{ y: [0, 8, 0] }} | |
| transition={{ repeat: Infinity, duration: 1.5 }} | |
| /> | |
| </motion.div> | |
| </motion.div> | |
| )} | |
| </div> | |
| {/* Seamless blend into next section */} | |
| <div | |
| className="absolute bottom-0 left-0 right-0 h-64 pointer-events-none" | |
| style={{ | |
| background: "linear-gradient(to bottom, transparent 0%, #050505 100%)" | |
| }} | |
| /> | |
| </div> | |
| </> | |
| ); | |
| } | |