Cards are pinned to the inner surface of a 1100px-radius CSS 3D sphere, distributed across a phi/theta grid with seeded random jitter on position and Z rotation. Scroll wheel and vertical drag accumulate into a camera X rotation (lerped at 0.06), while horizontal mouse position tilts the camera Y axis. Friction is 0.93 on the velocity accumulator, so a flick coasts to a stop. A small reticle dot marks the viewport centre.
Source
"use client";
import { useEffect, useRef } from "react";
import ScrambleLink from "../ScrambleLink";
const CARD_DATA = [
{ bg: "#1a1a2e", color: "#fff", title: "Phantom", subtitle: "Display Serif", letter: "Ag", w: 240, h: 280 },
{ bg: "#e74c3c", color: "#fff", title: "Signal", subtitle: "Bold Sans", letter: "Rr", w: 200, h: 200 },
{ bg: "#f5f0e8", color: "#1a1a2e", title: "Whisper", subtitle: "Light Italic", letter: "Ww", w: 260, h: 190 },
{ bg: "#2d2b6b", color: "#fff", title: "Indigo", subtitle: "Mono Wide", letter: "Ii", w: 220, h: 260 },
{ bg: "#1a1a1a", color: "#fff", title: "Obsidian", subtitle: "Extra Bold", letter: "Dd", w: 280, h: 220 },
{ bg: "#e8e4dc", color: "#1a1a2e", title: "Bone", subtitle: "Thin Condensed", letter: "Bb", w: 190, h: 240 },
{ bg: "#8b1a1a", color: "#fff", title: "Crimson", subtitle: "Medium Slab", letter: "Cc", w: 220, h: 220 },
{ bg: "#f0e6ff", color: "#2d2b6b", title: "Haze", subtitle: "Regular Round", letter: "Hz", w: 260, h: 260 },
{ bg: "#d4edda", color: "#1a1a2e", title: "Sage", subtitle: "Book Weight", letter: "Sg", w: 200, h: 170 },
{ bg: "#fff3cd", color: "#1a1a2e", title: "Amber", subtitle: "Semi Bold", letter: "Am", w: 240, h: 200 },
{ bg: "#0044cc", color: "#fff", title: "Cobalt", subtitle: "Heavy Extended", letter: "Co", w: 280, h: 260 },
{ bg: "#333", color: "#fff", title: "Slate", subtitle: "Compressed", letter: "Sl", w: 220, h: 300 },
{ bg: "#fdf2f8", color: "#831843", title: "Blush", subtitle: "Light Wide", letter: "Bl", w: 240, h: 190 },
{ bg: "#0f172a", color: "#e2e8f0", title: "Void", subtitle: "Narrow Black", letter: "Vd", w: 200, h: 240 },
{ bg: "#fef9c3", color: "#1a1a2e", title: "Lemon", subtitle: "Regular Serif", letter: "Lm", w: 260, h: 220 },
{ bg: "#dc2626", color: "#fff", title: "Scarlet", subtitle: "Ultra Bold", letter: "Sc", w: 220, h: 200 },
{ bg: "#e0e7ff", color: "#312e81", title: "Mist", subtitle: "Book Italic", letter: "Ms", w: 240, h: 260 },
{ bg: "#1e1b4b", color: "#c7d2fe", title: "Abyss", subtitle: "Display Wide", letter: "Ab", w: 280, h: 240 },
{ bg: "#065f46", color: "#d1fae5", title: "Forest", subtitle: "Condensed Serif", letter: "Fr", w: 200, h: 220 },
{ bg: "#f97316", color: "#fff", title: "Tangerine", subtitle: "Black Wide", letter: "Tn", w: 260, h: 200 },
{ bg: "#4c1d95", color: "#e0e7ff", title: "Violet", subtitle: "Thin Extended", letter: "Vi", w: 230, h: 270 },
{ bg: "#fecaca", color: "#7f1d1d", title: "Coral", subtitle: "Light Slab", letter: "Cr", w: 250, h: 210 },
{ bg: "#164e63", color: "#cffafe", title: "Teal", subtitle: "Medium Mono", letter: "Tl", w: 210, h: 250 },
{ bg: "#fde68a", color: "#78350f", title: "Gold", subtitle: "Heavy Serif", letter: "Gd", w: 270, h: 230 },
];
const SPHERE_R = 1100;
const PERSP = 700;
const COLS = 4;
const ROWS = Math.ceil(CARD_DATA.length / COLS);
const PHI_SPREAD = 60;
const THETA_SPREAD = 22;
const LERP = 0.06;
const FRICTION = 0.93;
const MOUSE_Y_GAIN = 6;
function seededRandom(seed: number) {
let s = seed;
return () => {
s = (s * 16807 + 0) % 2147483647;
return (s - 1) / 2147483646;
};
}
interface Slot {
phi: number;
theta: number;
zRot: number;
card: (typeof CARD_DATA)[number];
}
function buildSlots(): Slot[] {
const rng = seededRandom(77);
const slots: Slot[] = [];
for (let i = 0; i < CARD_DATA.length; i++) {
const col = i % COLS;
const row = Math.floor(i / COLS);
const phi = ((col + 0.5) / COLS - 0.5) * PHI_SPREAD + (rng() - 0.5) * 8;
const theta = ((row + 0.5) / ROWS - 0.5) * THETA_SPREAD * ROWS + (rng() - 0.5) * 5;
slots.push({
phi,
theta,
zRot: (rng() - 0.5) * 4,
card: CARD_DATA[i],
});
}
return slots;
}
export default function ScatterCanvas() {
const wrapRef = useRef<HTMLDivElement>(null);
const worldRef = useRef<HTMLDivElement>(null);
const rafRef = useRef<number | null>(null);
const slotsRef = useRef<Slot[]>(buildSlots());
const state = useRef({
camX: 0,
camY: 0,
targetCamX: 0,
targetCamY: 0,
scrollAccum: 0,
velocity: 0,
dragging: false,
lastY: 0,
mouseXNorm: 0.5,
});
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
function tick() {
const s = state.current;
if (!s.dragging) {
s.velocity *= FRICTION;
s.scrollAccum += s.velocity;
}
s.targetCamX = s.scrollAccum * 0.08;
s.targetCamY = -(s.mouseXNorm - 0.5) * MOUSE_Y_GAIN;
s.camX += (s.targetCamX - s.camX) * LERP;
s.camY += (s.targetCamY - s.camY) * LERP;
if (worldRef.current) {
worldRef.current.style.transform =
`rotateX(${s.camX.toFixed(3)}deg) rotateY(${s.camY.toFixed(3)}deg)`;
}
rafRef.current = requestAnimationFrame(tick);
}
const onWheel = (e: WheelEvent) => {
e.preventDefault();
state.current.scrollAccum += e.deltaY * 0.4;
};
const onMove = (e: MouseEvent) => {
const s = state.current;
const rect = wrap.getBoundingClientRect();
s.mouseXNorm = (e.clientX - rect.left) / rect.width;
if (s.dragging) {
const dy = e.clientY - s.lastY;
s.velocity = -dy * 0.6;
s.scrollAccum -= dy * 0.6;
s.lastY = e.clientY;
}
};
const onDown = (e: MouseEvent) => {
state.current.dragging = true;
state.current.lastY = e.clientY;
state.current.velocity = 0;
wrap.style.cursor = "grabbing";
};
const onUp = () => {
state.current.dragging = false;
wrap.style.cursor = "grab";
};
wrap.addEventListener("wheel", onWheel, { passive: false });
wrap.addEventListener("mousemove", onMove);
wrap.addEventListener("mousedown", onDown);
window.addEventListener("mouseup", onUp);
rafRef.current = requestAnimationFrame(tick);
return () => {
wrap.removeEventListener("wheel", onWheel);
wrap.removeEventListener("mousemove", onMove);
wrap.removeEventListener("mousedown", onDown);
window.removeEventListener("mouseup", onUp);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, []);
const slots = slotsRef.current;
return (
<div
ref={wrapRef}
style={{
position: "fixed",
inset: 0,
background: "#fff",
overflow: "hidden",
cursor: "grab",
perspective: PERSP,
perspectiveOrigin: "50% 50%",
}}
>
{/* Reticle */}
<div
style={{
position: "fixed",
left: "50%",
top: "50%",
width: 10,
height: 10,
marginLeft: -5,
marginTop: -5,
borderRadius: "50%",
border: "1.5px solid rgba(0,0,0,0.12)",
zIndex: 30,
pointerEvents: "none",
}}
/>
{/* 3D world — cards on inner surface of sphere */}
<div
ref={worldRef}
style={{
position: "absolute",
left: "50%",
top: "50%",
transformStyle: "preserve-3d",
willChange: "transform",
}}
>
{slots.map((slot, i) => (
<div
key={i}
style={{
position: "absolute",
width: slot.card.w,
height: slot.card.h,
marginLeft: -(slot.card.w / 2),
marginTop: -(slot.card.h / 2),
borderRadius: 8,
backgroundColor: slot.card.bg,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
boxShadow: "0 2px 16px rgba(0,0,0,0.06)",
backfaceVisibility: "hidden",
transform:
`rotateY(${slot.phi}deg) rotateX(${-slot.theta}deg) translateZ(${-SPHERE_R}px) rotateZ(${slot.zRot}deg)`,
}}
>
<span
style={{
fontFamily: "var(--font-flux), Georgia, serif",
fontVariationSettings: "'wght' 300, 'SRIF' 500",
fontSize: Math.min(slot.card.w, slot.card.h) * 0.3,
color: slot.card.color,
lineHeight: 1,
}}
>
{slot.card.letter}
</span>
<span
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 600, 'SRIF' 0",
fontSize: 13,
color: slot.card.color,
marginTop: 12,
letterSpacing: "0.04em",
opacity: 0.9,
}}
>
{slot.card.title}
</span>
<span
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 300, 'SRIF' 100",
fontSize: 10,
color: slot.card.color,
marginTop: 4,
opacity: 0.45,
letterSpacing: "0.06em",
textTransform: "uppercase",
}}
>
{slot.card.subtitle}
</span>
</div>
))}
</div>
{/* Nav */}
<div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
<ScrambleLink
from="SCATTER"
to="← LAB"
href="/lab"
className="text-[11px] tracking-[0.1em] text-[#999] uppercase hover:text-[#333] transition-colors"
style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }}
/>
</div>
</div>
);
}