// author: Khoa Phan "use client" import React, { ElementType, HTMLAttributes, useEffect, useMemo } from "react" import type { DOMKeyframesDefinition, AnimationOptions } from "motion" import { useAnimate } from "motion/react" import { cn } from "@/lib/utils" interface ImageTrailProps extends HTMLAttributes { /** * The content to be displayed */ children: React.ReactNode /** * HTML Tag */ as?: ElementType /** * How much distance in pixels the mouse has to travel to trigger of an element to appear. */ threshold?: number /** * The intensity for the momentum movement after showing the element. The value will be clamped > 0 and <= 1.0. Defaults to 0.3. */ intensity?: number /** * Animation Keyframes for defining the animation sequence. Example: { scale: [0, 1, 1, 0] } */ keyframes?: DOMKeyframesDefinition /** * Options for the animation/keyframes. Example: { duration: 1, times: [0, 0.1, 0.9, 1] } */ keyframesOptions?: AnimationOptions /** * Animation keyframes for the x and y positions after showing the element. Describes how the element should try to arrive at the mouse position. */ trailElementAnimationKeyframes?: { x?: AnimationOptions y?: AnimationOptions } /** * The number of times the children will be repeated. Defaults to 3. */ repeatChildren?: number /** * The base zIndex for all elements. Defaults to 0. */ baseZIndex?: number /** * Controls stacking order behavior. * - "new-on-top": newer elements stack above older ones (default) * - "old-on-top": older elements stay visually on top */ zIndexDirection?: "new-on-top" | "old-on-top" } interface ImageTrailItemProps extends HTMLAttributes { /** * HTML Tag */ as?: ElementType /** * The content to be displayed */ children: React.ReactNode } /** * Helper functions */ const MathUtils = { // linear interpolation lerp: (a: number, b: number, n: number) => (1 - n) * a + n * b, // distance between two points distance: (x1: number, y1: number, x2: number, y2: number) => Math.hypot(x2 - x1, y2 - y1), } const ImageTrail = ({ className, as = "div", children, threshold = 100, intensity = 0.3, keyframes, keyframesOptions, repeatChildren = 3, trailElementAnimationKeyframes = { x: { duration: 1, type: "tween", ease: "easeOut" }, y: { duration: 1, type: "tween", ease: "easeOut" }, }, baseZIndex = 0, zIndexDirection = "new-on-top", ...props }: ImageTrailProps) => { const allImages = React.useRef>(undefined) const currentId = React.useRef(0) const lastMousePos = React.useRef({ x: 0, y: 0 }) const cachedMousePos = React.useRef({ x: 0, y: 0 }) const [containerRef, animate] = useAnimate() const zIndices = React.useRef([]) const clampedIntensity = useMemo( () => Math.max(0.0001, Math.min(1, intensity)), [intensity] ) useEffect(() => { allImages.current = containerRef?.current?.querySelectorAll( ".image-trail-item" ) as NodeListOf zIndices.current = Array.from( { length: allImages.current.length }, (_, index) => index ) }, [containerRef, allImages]) const handleMouseMove = (e: React.MouseEvent) => { const containerRect = containerRef?.current?.getBoundingClientRect() const mousePos = { x: e.clientX - (containerRect?.left || 0), y: e.clientY - (containerRect?.top || 0), } cachedMousePos.current.x = MathUtils.lerp( cachedMousePos.current.x || mousePos.x, mousePos.x, clampedIntensity ) cachedMousePos.current.y = MathUtils.lerp( cachedMousePos.current.y || mousePos.y, mousePos.y, clampedIntensity ) const distance = MathUtils.distance( mousePos.x, mousePos.y, lastMousePos.current.x, lastMousePos.current.y ) if (distance > threshold && allImages?.current) { const N = allImages.current.length const current = currentId.current if (zIndexDirection === "new-on-top") { // Shift others down, put current on top for (let i = 0; i < N; i++) { if (i !== current) { zIndices.current[i] -= 1 } } zIndices.current[current] = N - 1 } else { // Shift others up, put current at bottom for (let i = 0; i < N; i++) { if (i !== current) { zIndices.current[i] += 1 } } zIndices.current[current] = 0 } allImages.current[current].style.display = "block" allImages.current.forEach((img, index) => { img.style.zIndex = String(zIndices.current[index] + baseZIndex) }) animate( allImages.current[currentId.current], { x: [ cachedMousePos.current.x - allImages.current[currentId.current].offsetWidth / 2, mousePos.x - allImages.current[currentId.current].offsetWidth / 2, ], y: [ cachedMousePos.current.y - allImages.current[currentId.current].offsetHeight / 2, mousePos.y - allImages.current?.[currentId.current].offsetHeight / 2, ], ...keyframes, }, { ...trailElementAnimationKeyframes.x, ...trailElementAnimationKeyframes.y, ...keyframesOptions, } ) currentId.current = (current + 1) % N lastMousePos.current = { x: mousePos.x, y: mousePos.y } } } const ElementTag = as ?? "div" return ( {Array.from({ length: repeatChildren }).map(() => ( <>{children} ))} ) } export const ImageTrailItem = ({ className, children, as = "div", ...props }: ImageTrailItemProps) => { const ElementTag = as ?? "div" return ( {children} ) } export default ImageTrail