Image Sphere Cloud
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
Photos scattered over a slowly turning invisible sphere that you can drag to spin and click to bring a photo forward.
Code
"use client";
import { useEffect, useRef } from "react";
import { mulberry32 } from "./stage";
/*
* A cloud of photos scattered over an invisible sphere that slowly turns.
* Every photo faces the camera; nearer ones grow, the hovered one grows more.
* Drag to spin (with a throw), click a photo to bring it forward, click again
* to let it go back.
*/
const IDS = [
"1506744038136-46273834b3fb",
"1501785888041-af3ef285b470",
"1470071459604-3b5ec3a7fe05",
"1441974231531-c6227db76b6e",
"1500530855697-b586d89ba3ee",
"1469474968028-56623f02e42e",
"1472214103451-9374bd1c798e",
"1447752875215-b2761acb3c5d",
"1433086966358-54859d0ed716",
"1518791841217-8f162f1e1131",
"1543466835-00a7907e9de1",
"1517849845537-4d257902454a",
"1501594907352-04cda38ebc29",
"1464822759023-fed622ff2c3b",
"1500534314209-a25ddb2bd429",
"1475924156734-496f6cac6ec1",
"1490750967868-88aa4486c946",
"1507525428034-b723cf961d3e",
"1511884642898-4c92249e20b6",
"1519681393784-d120267933ba",
];
const COUNT = 26;
const URLS = Array.from({ length: COUNT }, (_, i) => `https://images.unsplash.com/photo-${IDS[i % IDS.length]}?w=320&q=60&auto=format&fit=crop`);
const rnd = mulberry32(17);
const POINTS = Array.from({ length: COUNT }, (_, i) => {
const k = (i + 0.5) / COUNT;
const phi = Math.acos(1 - 2 * k);
const th = Math.PI * (1 + Math.sqrt(5)) * i;
const r = 1 + (rnd() - 0.5) * 0.4;
return {
x: Math.sin(phi) * Math.cos(th) * r,
y: Math.cos(phi) * r,
z: Math.sin(phi) * Math.sin(th) * r,
aspect: [1.5, 1.33, 0.8, 1, 1.5, 1.25][i % 6],
};
});
export default function ImageSphere() {
const hostRef = useRef<HTMLDivElement>(null);
const imgRefs = useRef<(HTMLImageElement | null)[]>([]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const imgs = imgRefs.current;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const state = POINTS.map(() => ({ scale: 0, opacity: 0, focus: 0, loaded: false }));
imgs.forEach((im, i) => {
if (!im) return;
if (im.complete && im.naturalWidth) state[i].loaded = true;
im.onload = () => (state[i].loaded = true);
});
let rx = 0.2;
let ry = 0;
let vx = 0;
let vy = 0;
let hovered = -1;
let focused = -1;
let dragging = false;
let down: { x: number; y: number; moved: number; lx: number; ly: number } | null = null;
let raf = 0;
let visible = true;
let last = performance.now();
const tick = (now: number) => {
raf = 0;
const dt = Math.min(0.05, Math.max(0, (now - last) / 1000));
last = now;
const w = host.clientWidth;
const h = host.clientHeight;
if (!dragging) {
ry += vy * dt + (reduced ? 0 : 0.12 * dt);
rx += vx * dt + (reduced ? 0 : 0.035 * dt * Math.sin(now / 4000));
const d = Math.exp(-dt * 3.2);
vx *= d;
vy *= d;
}
const cy = Math.cos(ry);
const sy = Math.sin(ry);
const cx = Math.cos(rx);
const sx = Math.sin(rx);
const R = Math.min(h * 0.36, w * 0.3);
const base = h * 0.17;
const ease = 1 - Math.exp(-dt * 9);
POINTS.forEach((pt, i) => {
const im = imgs[i];
if (!im) return;
const x1 = pt.x * cy + pt.z * sy;
const z1 = -pt.x * sy + pt.z * cy;
const y2 = pt.y * cx - z1 * sx;
const z2 = pt.y * sx + z1 * cx;
const persp = 3 / (3 - z2);
const st = state[i];
const targetFocus = focused === i ? 1 : 0;
st.focus += (targetFocus - st.focus) * (1 - Math.exp(-dt * 7));
const depthScale = persp * (hovered === i ? 1.2 : 1);
st.scale += (depthScale - st.scale) * ease;
const dim = focused >= 0 && focused !== i ? 0.16 : 1;
const depthAlpha = 0.45 + 0.55 * ((z2 + 1.2) / 2.4);
st.opacity += ((st.loaded ? Math.min(1, depthAlpha) * dim : 0) - st.opacity) * ease;
const px = w / 2 + x1 * R * persp;
const py = h / 2 - y2 * R * persp;
const f = st.focus;
const big = (h * 0.62) / base;
const X = px + (w / 2 - px) * f;
const Y = py + (h / 2 - py) * f;
const S = st.scale + (big - st.scale) * f;
im.style.transform = `translate(${X.toFixed(1)}px, ${Y.toFixed(1)}px) translate(-50%, -50%) scale(${S.toFixed(3)})`;
im.style.opacity = (st.opacity + (1 - st.opacity) * f * (st.loaded ? 1 : 0)).toFixed(3);
im.style.zIndex = String(f > 0.01 ? 1000 : Math.round((z2 + 2) * 100));
im.style.width = `${(base * Math.sqrt(pt.aspect)).toFixed(1)}px`;
im.style.height = `${(base / Math.sqrt(pt.aspect)).toFixed(1)}px`;
});
schedule();
};
let running = false;
const schedule = () => {
if (!raf && visible && !document.hidden) {
if (!running) last = performance.now();
running = true;
raf = requestAnimationFrame(tick);
}
};
const onDown = (e: PointerEvent) => {
down = { x: e.clientX, y: e.clientY, moved: 0, lx: e.clientX, ly: e.clientY };
dragging = true;
vx = 0;
vy = 0;
try {
host.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
};
const onMove = (e: PointerEvent) => {
const el = (e.target as HTMLElement).closest("[data-i]") as HTMLElement | null;
hovered = el ? Number(el.dataset.i) : -1;
if (!down) return;
const dx = e.clientX - down.lx;
const dy = e.clientY - down.ly;
down.lx = e.clientX;
down.ly = e.clientY;
down.moved = Math.max(down.moved, Math.hypot(e.clientX - down.x, e.clientY - down.y));
const k = 0.006;
ry += dx * k;
rx += dy * k;
vy = vy * 0.5 + dx * k * 60 * 0.5;
vx = vx * 0.5 + dy * k * 60 * 0.5;
};
const onUp = (e: PointerEvent) => {
if (down && down.moved < 6) {
// click: focus the photo under the pointer, or release the focused one
const el = document.elementFromPoint(e.clientX, e.clientY);
const target = el ? ((el as HTMLElement).closest("[data-i]") as HTMLElement | null) : null;
const i = target ? Number(target.dataset.i) : -1;
focused = focused >= 0 ? -1 : i;
vx = 0;
vy = 0;
}
down = null;
dragging = false;
};
const onLeave = () => {
hovered = -1;
};
host.addEventListener("pointerdown", onDown);
host.addEventListener("pointermove", onMove);
host.addEventListener("pointerup", onUp);
host.addEventListener("pointercancel", onUp);
host.addEventListener("pointerleave", onLeave);
const io = new IntersectionObserver((en) => {
visible = en.some((x) => x.isIntersecting);
if (visible) schedule();
else {
if (raf) cancelAnimationFrame(raf);
raf = 0;
running = false;
}
});
io.observe(host);
const onVis = () => {
if (document.hidden) {
if (raf) cancelAnimationFrame(raf);
raf = 0;
running = false;
} else schedule();
};
document.addEventListener("visibilitychange", onVis);
schedule();
return () => {
if (raf) cancelAnimationFrame(raf);
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
host.removeEventListener("pointerdown", onDown);
host.removeEventListener("pointermove", onMove);
host.removeEventListener("pointerup", onUp);
host.removeEventListener("pointercancel", onUp);
host.removeEventListener("pointerleave", onLeave);
imgs.forEach((im) => {
if (im) im.onload = null;
});
};
}, []);
return (
<div
ref={hostRef}
className="relative h-full w-full select-none overflow-hidden bg-white"
style={{ cursor: "grab", touchAction: "pan-y", background: "#ffffff" }}
>
{URLS.map((src, i) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={i}
ref={(el) => {
imgRefs.current[i] = el;
}}
data-i={i}
src={src}
alt=""
draggable={false}
loading="lazy"
decoding="async"
style={{
position: "absolute",
left: 0,
top: 0,
opacity: 0,
objectFit: "cover",
borderRadius: 3,
willChange: "transform, opacity",
boxShadow: "0 6px 18px rgba(0,0,0,0.12)",
pointerEvents: "auto",
}}
/>
))}
</div>
);
}
Prompt
Build "Image Sphere Cloud", a 3d animation. What it does: Photos scattered over a slowly turning invisible sphere that you can drag to spin and click to bring a photo forward. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS. Technique: an HTML canvas, a requestAnimationFrame loop, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://www.arlan.me/vault (Arlan, MIT).