{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "image-trail", "title": "Image Trail", "description": "A framed mouse-tracking image trail with momentum, captions, and customizable styling.", "dependencies": [ "framer-motion" ], "registryDependencies": [], "files": [ { "path": "registry/spark-ui/image-trail.tsx", "content": "// author: Khoa Phan \n\"use client\";\n\nimport React, { ElementType, HTMLAttributes, useEffect, useMemo, useRef } from \"react\";\nimport { useAnimate } from \"framer-motion\";\nimport type { DOMKeyframesDefinition, AnimationOptions } from \"framer-motion\";\nimport { cn } from \"@/lib/utils\";\n\ninterface ImageTrailProps extends HTMLAttributes {\n /**\n * The content to be displayed\n */\n children: React.ReactNode;\n\n /**\n * HTML Tag\n */\n as?: ElementType;\n\n /**\n * How much distance in pixels the mouse has to travel to trigger of an element to appear.\n */\n threshold?: number;\n\n /**\n * The intensity for the momentum movement after showing the element. The value will be clamped > 0 and <= 1.0. Defaults to 0.3.\n */\n intensity?: number;\n\n /**\n * Animation Keyframes for defining the animation sequence. Example: { scale: [0, 1, 1, 0] }\n */\n keyframes?: DOMKeyframesDefinition;\n\n /**\n * Options for the animation/keyframes. Example: { duration: 1, times: [0, 0.1, 0.9, 1] }\n */\n keyframesOptions?: AnimationOptions;\n\n /**\n * Animation keyframes for the x and y positions after showing the element. Describes how the element should try to arrive at the mouse position.\n */\n trailElementAnimationKeyframes?: {\n x?: AnimationOptions;\n y?: AnimationOptions;\n };\n\n /**\n * The number of times the children will be repeated. Defaults to 3.\n */\n repeatChildren?: number;\n\n /**\n * The base zIndex for all elements. Defaults to 0.\n */\n baseZIndex?: number;\n\n /**\n * Controls stacking order behavior.\n * - \"new-on-top\": newer elements stack above older ones (default)\n * - \"old-on-top\": older elements stay visually on top\n */\n zIndexDirection?: \"new-on-top\" | \"old-on-top\";\n}\n\ninterface ImageTrailItemProps extends HTMLAttributes {\n /**\n * HTML Tag\n */\n as?: ElementType;\n\n /**\n * The content to be displayed\n */\n children: React.ReactNode;\n\n /** Visual treatment for the trail item. */\n variant?: \"plain\" | \"framed\" | \"glass\";\n\n /** Corner radius applied to the item and its media. */\n radius?: \"none\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"full\";\n}\n\nconst radiusStyles = {\n none: \"rounded-none [&>img]:rounded-none\",\n sm: \"rounded-sm [&>img]:rounded-[2px]\",\n md: \"rounded-md [&>img]:rounded-[4px]\",\n lg: \"rounded-lg [&>img]:rounded-[6px]\",\n xl: \"rounded-2xl [&>img]:rounded-xl\",\n full: \"rounded-full [&>img]:rounded-full\",\n};\n\nconst variantStyles = {\n plain: \"overflow-hidden\",\n framed:\n \"overflow-hidden border border-white/40 bg-background/90 p-1.5 shadow-[0_20px_50px_-18px_rgba(0,0,0,0.65)] ring-1 ring-black/10 dark:border-white/15 dark:ring-white/10\",\n glass:\n \"overflow-hidden border border-white/30 bg-white/15 p-1.5 shadow-2xl ring-1 ring-white/15 backdrop-blur-md\",\n};\n\n/**\n * Helper functions\n */\nconst MathUtils = {\n // linear interpolation\n lerp: (a: number, b: number, n: number) => (1 - n) * a + n * b,\n // distance between two points\n distance: (x1: number, y1: number, x2: number, y2: number) =>\n Math.hypot(x2 - x1, y2 - y1),\n};\n\nexport const ImageTrail = ({\n className,\n as = \"div\",\n children,\n threshold = 100,\n intensity = 0.3,\n keyframes,\n keyframesOptions,\n repeatChildren = 3,\n trailElementAnimationKeyframes = {\n x: { duration: 1, type: \"tween\", ease: \"easeOut\" },\n y: { duration: 1, type: \"tween\", ease: \"easeOut\" },\n },\n baseZIndex = 0,\n zIndexDirection = \"new-on-top\",\n ...props\n}: ImageTrailProps) => {\n const allImages = useRef | null>(null);\n const currentId = useRef(0);\n const lastMousePos = useRef<{ x: number; y: number } | null>(null);\n const cachedMousePos = useRef<{ x: number; y: number } | null>(null);\n const [containerRef, animate] = useAnimate();\n const zIndices = useRef([]);\n\n const clampedIntensity = useMemo(\n () => Math.max(0.0001, Math.min(1, intensity)),\n [intensity]\n );\n\n useEffect(() => {\n if (containerRef.current) {\n allImages.current = containerRef.current.querySelectorAll(\n \".image-trail-item\"\n ) as NodeListOf;\n\n zIndices.current = Array.from(\n { length: allImages.current.length },\n (_, index) => index\n );\n }\n }, [containerRef]);\n\n const handleMouseMove = (e: React.MouseEvent) => {\n if (!containerRef.current || !allImages.current || allImages.current.length === 0) return;\n\n const containerRect = containerRef.current.getBoundingClientRect();\n const mousePos = {\n x: e.clientX - containerRect.left,\n y: e.clientY - containerRect.top,\n };\n\n const lastCachedX = cachedMousePos.current ? cachedMousePos.current.x : mousePos.x;\n const lastCachedY = cachedMousePos.current ? cachedMousePos.current.y : mousePos.y;\n\n cachedMousePos.current = {\n x: MathUtils.lerp(lastCachedX, mousePos.x, clampedIntensity),\n y: MathUtils.lerp(lastCachedY, mousePos.y, clampedIntensity),\n };\n\n if (!lastMousePos.current) {\n lastMousePos.current = { x: mousePos.x, y: mousePos.y };\n return;\n }\n\n const prevX = lastMousePos.current.x;\n const prevY = lastMousePos.current.y;\n\n const distance = MathUtils.distance(\n mousePos.x,\n mousePos.y,\n prevX,\n prevY\n );\n\n if (distance > threshold) {\n const steps = Math.floor(distance / threshold);\n const N = allImages.current.length;\n\n for (let s = 1; s <= steps; s++) {\n const t = s / steps;\n const interpX = MathUtils.lerp(prevX, mousePos.x, t);\n const interpY = MathUtils.lerp(prevY, mousePos.y, t);\n\n const interpCachedX = MathUtils.lerp(lastCachedX, cachedMousePos.current.x, t);\n const interpCachedY = MathUtils.lerp(lastCachedY, cachedMousePos.current.y, t);\n\n const current = currentId.current;\n\n if (zIndexDirection === \"new-on-top\") {\n // Shift others down, put current on top\n for (let i = 0; i < N; i++) {\n if (i !== current) {\n zIndices.current[i] -= 1;\n }\n }\n zIndices.current[current] = N - 1;\n } else {\n // Shift others up, put current at bottom\n for (let i = 0; i < N; i++) {\n if (i !== current) {\n zIndices.current[i] += 1;\n }\n }\n zIndices.current[current] = 0;\n }\n\n const activeEl = allImages.current[current];\n if (activeEl) {\n activeEl.style.display = \"block\";\n allImages.current.forEach((img, index) => {\n img.style.zIndex = String(zIndices.current[index] + baseZIndex);\n });\n\n const startX = interpCachedX - activeEl.offsetWidth / 2;\n const endX = interpX - activeEl.offsetWidth / 2;\n const startY = interpCachedY - activeEl.offsetHeight / 2;\n const endY = interpY - activeEl.offsetHeight / 2;\n\n animate(\n activeEl,\n {\n x: [startX, endX],\n y: [startY, endY],\n ...keyframes,\n } as DOMKeyframesDefinition,\n {\n ...trailElementAnimationKeyframes.x,\n ...trailElementAnimationKeyframes.y,\n ...keyframesOptions,\n }\n );\n currentId.current = (current + 1) % N;\n }\n }\n\n lastMousePos.current = { x: mousePos.x, y: mousePos.y };\n }\n };\n\n const ElementTag = as;\n\n return (\n \n {Array.from({ length: repeatChildren }).map((_, i) => (\n {children}\n ))}\n \n );\n};\n\nexport const ImageTrailItem = ({\n className,\n children,\n as = \"div\",\n variant = \"framed\",\n radius = \"xl\",\n ...props\n}: ImageTrailItemProps) => {\n const ElementTag = as;\n return (\n