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
"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>
);
}