NEMAWASHI LAB

Cube

CSS 3D

A CSS 3D cube (perspective 1200 px, preserve-3d) with six faces, each holding a 5x5 grid of the letter "G" set in the KT Flux variable font. Dragging rotates the cube with pointer tracking and momentum decay (velocity *= 0.95 per frame). On every rotation update, each cell's z-depth after the combined rotateX/rotateY transform is computed, normalised to [0,1], and mapped to a font-variation-settings range (wght 200-600, SRIF 100-500) and a grey-to-black colour ramp -- so the visible face shows heavier, darker glyphs while receding faces fade to thin and light, a live Noordzij cube.

Source

CubeCanvas.tsx253 lines
"use client";

import { useRef, useState, useCallback, useEffect, useMemo } from "react";
import ScrambleLink from "../ScrambleLink";

const WGHT_MIN = 200;
const WGHT_MAX = 600;
const SRIF_MIN = 100;
const SRIF_MAX = 500;

const GRID = 5;
const CELL_SIZE = 56;
const CUBE_SIZE = GRID * CELL_SIZE;
const HALF = CUBE_SIZE / 2;

type FaceName = "front" | "back" | "left" | "right" | "top" | "bottom";

const FACES: FaceName[] = ["front", "back", "left", "right", "top", "bottom"];

function faceTransform(face: FaceName): string {
  switch (face) {
    case "front":
      return `translateZ(${HALF}px)`;
    case "back":
      return `rotateY(180deg) translateZ(${HALF}px)`;
    case "left":
      return `rotateY(-90deg) translateZ(${HALF}px)`;
    case "right":
      return `rotateY(90deg) translateZ(${HALF}px)`;
    case "top":
      return `rotateX(90deg) translateZ(${HALF}px)`;
    case "bottom":
      return `rotateX(-90deg) translateZ(${HALF}px)`;
  }
}

// 3D position of each cell on each face (computed once)
interface CellPos {
  x: number;
  y: number;
  z: number;
}

function buildFaceCells(face: FaceName): CellPos[] {
  const cells: CellPos[] = [];
  for (let row = 0; row < GRID; row++) {
    for (let col = 0; col < GRID; col++) {
      const u = (col / (GRID - 1) - 0.5) * CUBE_SIZE;
      const v = (row / (GRID - 1) - 0.5) * CUBE_SIZE;
      switch (face) {
        case "front":  cells.push({ x: u, y: v, z: HALF }); break;
        case "back":   cells.push({ x: -u, y: v, z: -HALF }); break;
        case "left":   cells.push({ x: -HALF, y: v, z: u }); break;
        case "right":  cells.push({ x: HALF, y: v, z: -u }); break;
        case "top":    cells.push({ x: u, y: -HALF, z: v }); break;
        case "bottom": cells.push({ x: u, y: HALF, z: -v }); break;
      }
    }
  }
  return cells;
}

function faceNormal(face: FaceName): [number, number, number] {
  switch (face) {
    case "front":  return [0, 0, 1];
    case "back":   return [0, 0, -1];
    case "left":   return [-1, 0, 0];
    case "right":  return [1, 0, 0];
    case "top":    return [0, -1, 0];
    case "bottom": return [0, 1, 0];
  }
}

const FACE_DATA = FACES.map((face) => ({
  name: face,
  transform: faceTransform(face),
  cells: buildFaceCells(face),
  normal: faceNormal(face),
}));

// CSS `rotateX(rx) rotateY(ry)` applies rotateY first, then rotateX.
// Z component after Ry then Rx:
function transformedZ(px: number, py: number, pz: number, cosRx: number, sinRx: number, cosRy: number, sinRy: number): number {
  return py * sinRx + (-px * sinRy + pz * cosRy) * cosRx;
}

function lerpColor(t: number): string {
  const gray = Math.round(210 - 190 * t);
  return `rgb(${gray},${gray},${gray})`;
}

export default function CubeCanvas() {
  const dragging = useRef(false);
  const lastPos = useRef({ x: 0, y: 0 });
  const velocity = useRef({ x: 0, y: 0 });
  const rafRef = useRef<number>(0);

  const [rotation, setRotation] = useState({ x: -25, y: -35 });

  const startMomentum = useCallback(() => {
    let vx = velocity.current.x;
    let vy = velocity.current.y;

    function tick() {
      vx *= 0.95;
      vy *= 0.95;
      if (Math.abs(vx) < 0.05 && Math.abs(vy) < 0.05) return;
      setRotation((prev) => ({ x: prev.x + vx, y: prev.y + vy }));
      rafRef.current = requestAnimationFrame(tick);
    }

    rafRef.current = requestAnimationFrame(tick);
  }, []);

  const handlePointerDown = useCallback((e: React.PointerEvent) => {
    dragging.current = true;
    lastPos.current = { x: e.clientX, y: e.clientY };
    velocity.current = { x: 0, y: 0 };
    cancelAnimationFrame(rafRef.current);
    (e.target as HTMLElement).setPointerCapture(e.pointerId);
  }, []);

  const handlePointerMove = useCallback((e: React.PointerEvent) => {
    if (!dragging.current) return;
    const dx = e.clientX - lastPos.current.x;
    const dy = e.clientY - lastPos.current.y;
    lastPos.current = { x: e.clientX, y: e.clientY };
    velocity.current = { x: -dy * 0.3, y: dx * 0.3 };
    setRotation((prev) => ({ x: prev.x - dy * 0.3, y: prev.y + dx * 0.3 }));
  }, []);

  const handlePointerUp = useCallback(() => {
    dragging.current = false;
    startMomentum();
  }, [startMomentum]);

  useEffect(() => {
    return () => cancelAnimationFrame(rafRef.current);
  }, []);

  const cellStyles = useMemo(() => {
    const rx = (rotation.x * Math.PI) / 180;
    const ry = (rotation.y * Math.PI) / 180;
    const cosRx = Math.cos(rx), sinRx = Math.sin(rx);
    const cosRy = Math.cos(ry), sinRy = Math.sin(ry);

    const allZ = FACE_DATA.map(({ cells }) =>
      cells.map((c) => transformedZ(c.x, c.y, c.z, cosRx, sinRx, cosRy, sinRy))
    );
    const flatZ = allZ.flat();
    const zMin = Math.min(...flatZ);
    const zMax = Math.max(...flatZ);
    const zRange = zMax - zMin || 1;

    return allZ.map((zVals) =>
      zVals.map((z) => {
        const tLinear = 1 - (z - zMin) / zRange;
        const t = Math.pow(tLinear, 0.75);
        const wght = Math.round(WGHT_MIN + t * (WGHT_MAX - WGHT_MIN));
        const srif = Math.round(SRIF_MIN + t * (SRIF_MAX - SRIF_MIN));
        const color = lerpColor(t);
        return { wght, srif, color };
      })
    );
  }, [rotation]);

  return (
    <div
      className="fixed inset-0 select-none bg-[#f0eeeb] flex items-center justify-center overflow-hidden"
      style={{ cursor: dragging.current ? "grabbing" : "grab" }}
    >
      <div
        style={{ perspective: "1200px" }}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onPointerCancel={handlePointerUp}
        className="touch-none"
      >
        <div
          style={{
            width: CUBE_SIZE,
            height: CUBE_SIZE,
            position: "relative",
            transformStyle: "preserve-3d",
            transform: `rotateX(${rotation.x}deg) rotateY(${rotation.y}deg)`,
          }}
        >
          {FACE_DATA.map(({ name, transform }, faceIdx) => (
            <div
              key={name}
              style={{
                position: "absolute",
                width: CUBE_SIZE,
                height: CUBE_SIZE,
                transform,
                backfaceVisibility: "hidden",
                display: "grid",
                gridTemplateColumns: `repeat(${GRID}, 1fr)`,
                gridTemplateRows: `repeat(${GRID}, 1fr)`,
                placeItems: "center",
              }}
            >
              {cellStyles[faceIdx].map((style, i) => (
                <span
                  key={i}
                  className="font-[family-name:var(--font-kt-flux)]"
                  style={{
                    fontSize: CELL_SIZE * 0.9,
                    lineHeight: 1,
                    fontVariationSettings: `'wght' ${style.wght}, 'SRIF' ${style.srif}`,
                    color: style.color,
                  }}
                >
                  G
                </span>
              ))}
            </div>
          ))}
        </div>
      </div>

      <div
        className="fixed bottom-8 left-8 pointer-events-none font-[family-name:var(--font-flux)]"
        style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
      >
        <p className="text-[12px] tracking-[0.08em] text-foreground/50 uppercase">
          NOORDZIJ CUBE
        </p>
        <p className="text-[12px] tracking-[0.08em] mt-1 text-foreground/30">
          Drag to rotate
        </p>
      </div>

      <div className="fixed bottom-8 right-8 pointer-events-none font-[family-name:var(--font-flux)]" style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}>
        <p className="text-[12px] tracking-[0.08em] text-foreground/30 uppercase">
          KT Flux · wght × srif
        </p>
      </div>

      <div className="fixed top-8 left-8 z-10">
        <ScrambleLink
          from="NEMAWASHI LAB"
          to="← BACK"
          href="/lab"
          className="text-[14px] tracking-[0.08em] text-foreground/60 uppercase pointer-events-auto font-[family-name:var(--font-flux)]"
          style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
        />
      </div>
    </div>
  );
}

NEMAWASHI — Kotaro Abe