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

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 = [ "/cdn/effects/magnetic-image-trail/magnetic-image-trail-distortion.jpg?v=3", "/cdn/effects/magnetic-image-trail/magnetic-image-trail-img01.webp?v=3", "/cdn/effects/magnetic-image-trail/magnetic-image-trail-img02.webp?v=3", "/cdn/effects/magnetic-image-trail/magnetic-image-trail-img03.webp?v=3", "/cdn/effects/magnetic-image-trail/magnetic-image-trail-img04.png?v=3", "/cdn/effects/magnetic-image-trail/magnetic-image-trail-img05.png?v=3", "/cdn/effects/magnetic-image-trail/magnetic-image-trail-img06.png?v=3", "/cdn/effects/magnetic-image-trail/magnetic-image-trail-img07.png?v=3", "/cdn/effects/magnetic-image-trail/magnetic-image-trail-img08.jpg?v=3", "/cdn/effects/magnetic-image-trail/magnetic-image-trail-img09.jpg?v=3", "/cdn/effects/magnetic-image-trail/magnetic-image-trail-img10.jpg?v=3", ]; 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)) }

Preview behavior

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

Last updated on