NEMAWASHI LAB

Scatter

CSS 3D

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.

AgPhantomDisplay Serif
RrSignalBold Sans
WwWhisperLight Italic
IiIndigoMono Wide
DdObsidianExtra Bold
BbBoneThin Condensed
CcCrimsonMedium Slab
HzHazeRegular Round
SgSageBook Weight
AmAmberSemi Bold
CoCobaltHeavy Extended
SlSlateCompressed
BlBlushLight Wide
VdVoidNarrow Black
LmLemonRegular Serif
ScScarletUltra Bold
MsMistBook Italic
AbAbyssDisplay Wide
FrForestCondensed Serif
TnTangerineBlack Wide
ViVioletThin Extended
CrCoralLight Slab
TlTealMedium Mono
GdGoldHeavy Serif

Source

ScatterCanvas.tsx289 lines
"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>
  );
}

NEMAWASHI — Kotaro Abe