|
| 1 | +import React, { createRef, forwardRef, useCallback, useImperativeHandle, useRef } from "react"; |
| 2 | +import { motion, useAnimation } from "framer-motion"; |
| 3 | + |
| 4 | +import { useMousePosition } from "@/hooks/use-mouse-position"; |
| 5 | +import { getDistance, lerp } from "@/lib/utils"; |
| 6 | + |
| 7 | +interface AnimatedImageRef { |
| 8 | + show: ({ |
| 9 | + x, |
| 10 | + y, |
| 11 | + newX, |
| 12 | + newY, |
| 13 | + zIndex, |
| 14 | + }: { |
| 15 | + x: number; |
| 16 | + y: number; |
| 17 | + zIndex: number; |
| 18 | + newX: number; |
| 19 | + newY: number; |
| 20 | + }) => void; |
| 21 | + isActive: () => boolean; |
| 22 | +} |
| 23 | + |
| 24 | +const AnimatedImage = forwardRef<AnimatedImageRef, { src: string }>(({ src }, ref) => { |
| 25 | + const controls = useAnimation(); |
| 26 | + const isRunning = useRef(false); |
| 27 | + const imgRef = useRef<HTMLImageElement>(null); |
| 28 | + |
| 29 | + useImperativeHandle(ref, () => ({ |
| 30 | + isActive: () => isRunning.current, |
| 31 | + show: async ({ |
| 32 | + x, |
| 33 | + y, |
| 34 | + newX, |
| 35 | + newY, |
| 36 | + zIndex, |
| 37 | + }: { |
| 38 | + x: number; |
| 39 | + y: number; |
| 40 | + zIndex: number; |
| 41 | + newX: number; |
| 42 | + newY: number; |
| 43 | + }) => { |
| 44 | + const rect = imgRef.current?.getBoundingClientRect(); |
| 45 | + if (!rect) { |
| 46 | + return; |
| 47 | + } |
| 48 | + |
| 49 | + const center = (posX: number, posY: number) => { |
| 50 | + const coords = { |
| 51 | + x: posX - rect.width / 2, |
| 52 | + y: posY - rect.height / 2, |
| 53 | + }; |
| 54 | + return `translate(${coords.x}px, ${coords.y}px)`; |
| 55 | + }; |
| 56 | + |
| 57 | + controls.stop(); |
| 58 | + |
| 59 | + controls.set({ |
| 60 | + opacity: isRunning.current ? 1 : 0.75, |
| 61 | + zIndex, |
| 62 | + transform: `${center(x, y)} scale(1)`, |
| 63 | + transition: { ease: "circOut" }, |
| 64 | + }); |
| 65 | + |
| 66 | + isRunning.current = true; |
| 67 | + |
| 68 | + await controls.start({ |
| 69 | + opacity: 1, |
| 70 | + transform: `${center(newX, newY)} scale(1)`, |
| 71 | + transition: { duration: 0.9, ease: "circOut" }, |
| 72 | + }); |
| 73 | + |
| 74 | + await Promise.all([ |
| 75 | + controls.start({ |
| 76 | + transition: { duration: 1, ease: "easeInOut" }, |
| 77 | + transform: `${center(newX, newY)} scale(0.1)`, |
| 78 | + }), |
| 79 | + controls.start({ |
| 80 | + opacity: 0, |
| 81 | + transition: { duration: 1.1, ease: "easeOut" }, |
| 82 | + }), |
| 83 | + ]); |
| 84 | + |
| 85 | + isRunning.current = false; |
| 86 | + }, |
| 87 | + })); |
| 88 | + |
| 89 | + return ( |
| 90 | + <motion.img |
| 91 | + ref={imgRef} |
| 92 | + initial={{ opacity: 0, scale: 1 }} |
| 93 | + animate={controls} |
| 94 | + src={src} |
| 95 | + alt="trail element" |
| 96 | + className="absolute h-56 w-44 object-cover" |
| 97 | + /> |
| 98 | + ); |
| 99 | +}); |
| 100 | + |
| 101 | +AnimatedImage.displayName = "AnimatedImage"; |
| 102 | + |
| 103 | +const images = [ |
| 104 | + "https://assets.lummi.ai/assets/Qma1aBRXFsApFohRJrpJczE5QXGY6HhHKz24ybuw1khbou?auto=format&w=500", |
| 105 | + "https://assets.lummi.ai/assets/QmZBpAeh18DHxVNEEcJErt1UXGjZYCedSidJ6cybrDZdeS?auto=format&w=500", |
| 106 | + "https://assets.lummi.ai/assets/QmbMZFEfk2qwQkkmXYncpvHapkNQF5HuTrcascJC7edpfW?auto=format&w=500", |
| 107 | + "https://assets.lummi.ai/assets/QmXm6HVi3wwGy3jaCmECfoL8AULPerjQQh6abKTVhFMewK?auto=format&w=500", |
| 108 | + "https://assets.lummi.ai/assets/QmRy3tpFDCbgA3CQgRpySTGN6tNdomQE96rMpV31HeBUUd?auto=format&w=500", |
| 109 | +]; |
| 110 | + |
| 111 | +const TrailingImage = () => { |
| 112 | + const containerRef = useRef<HTMLDivElement>(null); |
| 113 | + // Create a maximum of 20 trails for a smoother experience |
| 114 | + const trailsRef = useRef( |
| 115 | + Array.from({ length: Math.max(20, images.length) }, createRef<AnimatedImageRef>), |
| 116 | + ); |
| 117 | + |
| 118 | + const lastPosition = useRef({ x: 0, y: 0 }); |
| 119 | + const cachedPosition = useRef({ x: 0, y: 0 }); |
| 120 | + const imageIndex = useRef(0); |
| 121 | + const zIndex = useRef(1); |
| 122 | + |
| 123 | + const update = useCallback((cursor: { x: number; y: number }) => { |
| 124 | + const activeRefCount = trailsRef.current.filter((ref) => ref.current?.isActive()).length; |
| 125 | + if (activeRefCount === 0) { |
| 126 | + // Reset zIndex since there are no active references |
| 127 | + // This prevents zIndex from incrementing indefinitely |
| 128 | + zIndex.current = 1; |
| 129 | + } |
| 130 | + |
| 131 | + const distance = getDistance( |
| 132 | + cursor.x, |
| 133 | + cursor.y, |
| 134 | + lastPosition.current.x, |
| 135 | + lastPosition.current.y, |
| 136 | + ); |
| 137 | + const threshold = 50; |
| 138 | + |
| 139 | + const newCachePosition = { |
| 140 | + x: lerp(cachedPosition.current.x || cursor.x, cursor.x, 0.1), |
| 141 | + y: lerp(cachedPosition.current.y || cursor.y, cursor.y, 0.1), |
| 142 | + }; |
| 143 | + cachedPosition.current = newCachePosition; |
| 144 | + |
| 145 | + if (distance > threshold) { |
| 146 | + imageIndex.current = (imageIndex.current + 1) % trailsRef.current.length; |
| 147 | + zIndex.current = zIndex.current + 1; |
| 148 | + lastPosition.current = cursor; |
| 149 | + trailsRef.current[imageIndex.current].current?.show?.({ |
| 150 | + x: newCachePosition.x, |
| 151 | + y: newCachePosition.y, |
| 152 | + zIndex: zIndex.current, |
| 153 | + newX: cursor.x, |
| 154 | + newY: cursor.y, |
| 155 | + }); |
| 156 | + } |
| 157 | + }, []); |
| 158 | + |
| 159 | + useMousePosition(containerRef, update); |
| 160 | + |
| 161 | + return ( |
| 162 | + <div ref={containerRef} className="storybook-fix relative flex min-h-96 w-full"> |
| 163 | + {trailsRef.current.map((ref, index) => ( |
| 164 | + <AnimatedImage key={index} ref={ref} src={images[index % images.length]} /> |
| 165 | + ))} |
| 166 | + <div className="flex w-full flex-1 items-center justify-center p-4 text-center text-sm text-foreground md:text-3xl"> |
| 167 | + <div className="max-w-sm">Move your mouse over this element to see the effect</div> |
| 168 | + </div> |
| 169 | + </div> |
| 170 | + ); |
| 171 | +}; |
| 172 | + |
| 173 | +export default TrailingImage; |
0 commit comments