Skip to content
Skip to Content
DocsMagnetic Image Trail
Magnetic Image Trail

Magnetic Image Trail

A cluster of images follows your cursor with magnetic momentum and a diagonal orbit.

Move your cursor across the preview.

Component preview
Magnetic Image Trail

Install using CLI

npx shadcn@latest add "https://www.obsidianui.dev/r/magnetic-image-trail.json"

Usage

Code
"use client"; import { MagneticImageTrail } from "@/components/block/magnetic-image-trail"; export default function Demo() { return <MagneticImageTrail height="400px" className="w-full rounded-lg" />; }

Install Manually

1

Install dependencies

npm install clsx tailwind-merge
2

Copy the source code

Copy into components/block/magnetic-image-trail.jsx

"use client"; import { useCallback, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; const imageUrls = [ "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/magnetic-image-trail/magnetic-image-trail-distortion.jpg", "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/magnetic-image-trail/magnetic-image-trail-img01.webp", "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/magnetic-image-trail/magnetic-image-trail-img02.webp", "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/magnetic-image-trail/magnetic-image-trail-img03.webp", "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/magnetic-image-trail/magnetic-image-trail-img04.png", "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/magnetic-image-trail/magnetic-image-trail-img05.png", "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/magnetic-image-trail/magnetic-image-trail-img06.png", "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/magnetic-image-trail/magnetic-image-trail-img07.png", "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/magnetic-image-trail/magnetic-image-trail-img08.jpg", "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/magnetic-image-trail/magnetic-image-trail-img09.jpg", "https://pub-830233752de349e29c6104a501b309d4.r2.dev/effects/magnetic-image-trail/magnetic-image-trail-img10.jpg", ]; const SLOTS = [ { y: 0, size: 1.19, w: 206, h: 167 }, // mid - just a bit bigger { y: -30, size: 1.10, w: 214, h: 167 }, // 2nd closest - little bigger, spread more { y: 30, size: 1.20, w: 214, h: 167 }, { y: -60, size: 1.06, w: 186, h: 144 }, // spread more { y: 60, size: 1.06, w: 186, h: 144 }, { y: -94, size: 0.91, w: 157, h: 122 }, // spread more { y: 94, size: 0.91, w: 157, h: 122 }, { y: -122, size: 0.80, w: 139, h: 109 }, { y: 122, size: 0.80, w: 139, h: 109 }, { y: -18, size: 1.04, w: 166, h: 129 }, // very little more than before { y: 18, size: 1.02, w: 161, h: 126 }, { y: -52, size: 0.97, w: 153, h: 118 }, { y: 52, size: 0.97, w: 153, h: 118 }, { y: -78, size: 0.87, w: 137, h: 108 }, { y: 78, size: 0.87, w: 137, h: 108 }, { y: -134, size: 0.70, w: 111, h: 83 }, // spread a bit more at edges { y: 134, size: 0.70, w: 111, h: 83 }, { y: 10, size: 0.91, w: 140, h: 108 }, // very little ]; // Faster + tighter motion. const SPEED = 0.0003; const MAX_SPEED = 0.0015; // Added max speed limit for generation const FOLLOW = 0.22; const SPREAD_X = 250; // Slightly more spread since sizes increased // Interaction Config const MOUSE_SPEED_BOOST = 0.003; const FORWARD_PUSH_AMOUNT = 40; const UPWARD_PUSH_AMOUNT = 30; const SCALE_OUT_MIN = 0.68; const SCALE_OUT_MAX = 1.48; function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } function lerp(a, b, t) { return a + (b - a) * t; } function drawRoundedImage(ctx, img, x, y, w, h, r = 0) { if (!img?.complete || img.naturalWidth <= 0) return; const imgAspect = img.naturalWidth / img.naturalHeight; const boxAspect = w / h; let sx, sy, sw, sh; if (imgAspect > boxAspect) { sh = img.naturalHeight; sw = sh * boxAspect; sx = (img.naturalWidth - sw) / 2; sy = 0; } else { sw = img.naturalWidth; sh = sw / boxAspect; sx = 0; sy = (img.naturalHeight - sh) / 2; } if (r > 0) { ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.clip(); ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h); ctx.restore(); return; } // No rounded corners. ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h); } /** * @param {{ images?: string[], children?: import("react").ReactNode, className?: string, * style?: import("react").CSSProperties, height?: import("react").CSSProperties["height"], * background?: string, textColor?: string, compositionScale?: number }} props */ function ImageTrail({ images = imageUrls, children = <>ObsidianUI.<br />Interfaces people remember.</>, className, style, height = "100vh", background = "#EDEBE6", textColor = "#111", compositionScale = 0.78, } = {}) { const wrapRef = useRef(null); const canvasRef = useRef(null); const rafRef = useRef(0); const pointer = useRef({ x: 0, y: 0 }); const lerpedPointer = useRef({ x: 0, y: 0 }); // lerped pointer position const smooth = useRef({ x: 0, y: 0 }); const dirRef = useRef({ x: 1, y: 0 }); const lastMoveAt = useRef(0); const phase = useRef(0); const lastTime = useRef(0); useEffect(() => { const canvas = canvasRef.current; const wrap = wrapRef.current; const ctx = canvas?.getContext("2d"); if (!wrap || !canvas || !ctx) return; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const sources = images.length ? images : imageUrls; const imgs = SLOTS.map((_, i) => { const img = new Image(); img.crossOrigin = "anonymous"; img.src = sources[i % sources.length]; return img; }); function resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); const w = wrap.clientWidth; const h = wrap.clientHeight; canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); canvas.style.width = `${w}px`; canvas.style.height = `${h}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); pointer.current = { x: w / 2, y: h / 2 }; lerpedPointer.current = { x: w / 2, y: h / 2 }; smooth.current = { x: w / 2, y: h / 2 }; if (reducedMotion.matches) frame(0); } function frame(now) { const W = wrap.clientWidth; const H = wrap.clientHeight; // Fit the original orbit and cards together inside embedded previews. const fitScale = Math.min(1, W / 720, H / 520) * Math.max(0, compositionScale); ctx.clearRect(0, 0, W, H); const dt = Math.min(32, now - (lastTime.current || now)); lastTime.current = now; // --- lerp pointer on every frame --- // The lerped pointer smoothly follows the actual pointer. // A lerp amount of 0.18 gives a nice responsive but eased motion. const LERP_AMOUNT = 0.18; lerpedPointer.current.x = lerp(lerpedPointer.current.x, pointer.current.x, LERP_AMOUNT); lerpedPointer.current.y = lerp(lerpedPointer.current.y, pointer.current.y, LERP_AMOUNT); // Phase increment will be calculated after mouse speed is determined const prevSX = smooth.current.x; const prevSY = smooth.current.y; // use lerpedPointer instead of pointer directly for the trailing smooth position smooth.current.x += (lerpedPointer.current.x - smooth.current.x) * FOLLOW; smooth.current.y += (lerpedPointer.current.y - smooth.current.y) * FOLLOW; const cx = smooth.current.x; const cy = smooth.current.y; const vx = cx - prevSX; const vy = cy - prevSY; const vmag = Math.hypot(vx, vy); if (vmag > 0.35) { const tx = vx / vmag; const ty = vy / vmag; dirRef.current.x = dirRef.current.x + (tx - dirRef.current.x) * 0.2; dirRef.current.y = dirRef.current.y + (ty - dirRef.current.y) * 0.2; const m = Math.hypot(dirRef.current.x, dirRef.current.y) || 1; dirRef.current.x /= m; dirRef.current.y /= m; lastMoveAt.current = now; } else if (!lastMoveAt.current) { lastMoveAt.current = now; } const speed01 = clamp(vmag / 18, 0, 1); // INCREASE GENERATING SPEED BASED ON MOUSE MOVEMENT, CLAMPED TO MAX_SPEED const currentSpeed = clamp(SPEED + speed01 * MOUSE_SPEED_BOOST, SPEED, MAX_SPEED); phase.current += dt * currentSpeed; const dir = dirRef.current; const cards = SLOTS.map((slot, i) => { const n = SLOTS.length; // Animation progress per image. const t = (phase.current + i / n) % 1; // -1 → 0 → 1 // This controls the movement along the diagonal path. const pathNorm = t * 2 - 1; // Diagonal movement direction. // Start: bottom-right // Center: middle // End: top-left // Bottom-left → top-right, but less steep. const moveX = pathNorm; const moveY = -pathNorm; // Keep the circular cluster shape. const yOffset = clamp(slot.y, -SPREAD_X * 0.82, SPREAD_X * 0.82); const circleWidthAtY = Math.sqrt(Math.max(0, SPREAD_X * SPREAD_X - yOffset * yOffset)) * 0.74; // Lower number = flatter/slanting movement. // 0.38 was too steep. const diagonalPush = SPREAD_X * 0.15; // Move the whole diagonal slightly upward, // so it starts a little above bottom-left // and ends a little below top-right. const verticalLift = -SPREAD_X * 0.06; const x = cx + moveX * circleWidthAtY; const y = cy + yOffset + moveY * diagonalPush + verticalLift; // Scale follows the same diagonal movement: // small → big at center → small const rawCenterScale = Math.max(0, 1 - Math.abs(pathNorm)); const easedCenterScale = rawCenterScale * rawCenterScale * (3 - 2 * rawCenterScale); const centerScale = lerp(0.06, 0.9, easedCenterScale); // Scale images up in the direction of mouse movement. // Images ahead of the mouse direction get larger. // Images behind the mouse direction get smaller. const dx = x - cx; const dy = y - cy; const directionalProjection = clamp( (dx * dir.x + dy * dir.y) / Math.max(1, SPREAD_X), -1, 1 ); // Scaling out works on OPPOSITE direction. // Images behind the mouse direction get larger (scale out). const oppositeProjection = -directionalProjection; const backwardAmount = (oppositeProjection + 1) * 0.5; const movementBoost = lerp(1, 1.18, speed01); const directionalScale = lerp(SCALE_OUT_MIN, SCALE_OUT_MAX, backwardAmount) * movementBoost; const scale = centerScale * directionalScale * 1.14; // Image translate effect on mouse move direction. // Images ahead of the mouse are pushed forward. const forwardPush = Math.max(0, directionalProjection) * speed01 * FORWARD_PUSH_AMOUNT; // Images scaling out (behind) translate a little up const upwardPush = Math.max(0, oppositeProjection) * speed01 * UPWARD_PUSH_AMOUNT; return { i, img: imgs[i % imgs.length], x: x + dir.x * forwardPush, y: y + dir.y * forwardPush - upwardPush, w: slot.w * slot.size * scale, h: slot.h * slot.size * scale, rot: 0, alpha: 1, order: i, }; }); // Stable stacking: never sort by `depth` (which changes during animation). cards.sort((a, b) => a.i - b.i); for (const card of cards) { if (card.w < 2 || card.h < 2) continue; ctx.save(); ctx.translate(cx + (card.x - cx) * fitScale, cy + (card.y - cy) * fitScale); ctx.globalAlpha = 1; ctx.shadowColor = "rgba(0,0,0,0.14)"; ctx.shadowBlur = 12 * fitScale; ctx.shadowOffsetY = 5 * fitScale; const width = card.w * fitScale; const height = card.h * fitScale; drawRoundedImage(ctx, card.img, -width / 2, -height / 2, width, height, 0); ctx.restore(); } if (!reducedMotion.matches) rafRef.current = requestAnimationFrame(frame); } function syncMotion() { cancelAnimationFrame(rafRef.current); lastTime.current = 0; if (reducedMotion.matches) frame(0); else rafRef.current = requestAnimationFrame(frame); } // Observe the preview itself; gallery layout changes need not resize the window. const observer = new ResizeObserver(resize); observer.observe(wrap); imgs.forEach((img) => { img.onload = () => { if (reducedMotion.matches) frame(0); }; }); resize(); syncMotion(); reducedMotion.addEventListener("change", syncMotion); return () => { cancelAnimationFrame(rafRef.current); observer.disconnect(); reducedMotion.removeEventListener("change", syncMotion); imgs.forEach((img) => { img.onload = null; }); }; }, [images, compositionScale]); const updatePointer = useCallback((e) => { const rect = wrapRef.current?.getBoundingClientRect(); if (!rect) return; // On mouse move, we set pointer to the actual event location (no lerp here). pointer.current = { x: e.clientX - rect.left, y: e.clientY - rect.top, }; }, []); return ( <section ref={wrapRef} className={cn("isolate", className)} onPointerMove={updatePointer} onPointerEnter={updatePointer} style={{ position: "relative", width: "100%", height, background, overflow: "hidden", containerType: "inline-size", ...style, }} > <div className="w-[70%] mx-auto" style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none", userSelect: "none", zIndex: 1, color: textColor, fontSize: "clamp(18px, 3.5cqw, 44px)", letterSpacing: "-0.03em", lineHeight: 1.05, textAlign: "center", padding: "0 20px", }} > {children} </div> <canvas ref={canvasRef} aria-hidden="true" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none", zIndex: 2, }} /> </section> ); } export { ImageTrail as MagneticImageTrail };
3

Add the supporting file

Copy into lib/utils.ts

import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }

Default demo media loads from ObsidianUI. Replace these URLs with your own assets for offline use.

Preview behavior

Move your cursor across the preview. The effect stays inside its container and respects reduced motion preferences.

Last updated on