Baktiyar Ablaikhan

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).