A grid where every column width and row height is a function -- sin wave, pinch toward centre, exponential funnel, uniform -- not a constant. Clicking cycles through seven distortion presets; each cell spring-animates to its new size (stiffness 60, damping 18) with a staggered delay proportional to its index, so the layout ripples rather than snaps. The filled/empty checkerboard and the distortion function together make it feel like a single typographic block stretching and breathing.
Source
"use client";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
interface Distortion {
name: string;
color: string;
cols: number;
rows: number;
colWidth: (col: number, total: number) => number;
rowHeight: (row: number, total: number) => number;
}
const distortions: Distortion[] = [
{
name: "EXPAND",
color: "#2A7FD4",
cols: 16,
rows: 12,
colWidth: (col, total) => 0.3 + (col / total) * 1.7,
rowHeight: (row, total) => 1.6 - (row / total) * 1.2,
},
{
name: "PINCH",
color: "#C1272D",
cols: 14,
rows: 14,
colWidth: (col, total) => {
const t = Math.abs(col - total / 2) / (total / 2);
return 0.4 + t * 1.2;
},
rowHeight: (row, total) => {
const t = Math.abs(row - total / 2) / (total / 2);
return 0.4 + t * 1.2;
},
},
{
name: "WAVE",
color: "#2C2C2C",
cols: 18,
rows: 12,
colWidth: (col, total) =>
0.6 + Math.sin((col / total) * Math.PI * 2) * 0.5,
rowHeight: (row, total) =>
0.6 + Math.cos((row / total) * Math.PI * 2) * 0.5,
},
{
name: "COMPRESS",
color: "#5B7553",
cols: 20,
rows: 10,
colWidth: (col, total) => 1.8 - (col / total) * 1.5,
rowHeight: (_row, _total) => 1,
},
{
name: "FUNNEL",
color: "#E87D2F",
cols: 16,
rows: 16,
colWidth: (col, total) => {
const t = col / total;
return 0.3 + t * t * 2;
},
rowHeight: (row, total) => {
const t = row / total;
return 2 - t * t * 1.5;
},
},
{
name: "BREATHE",
color: "#4A6FA5",
cols: 14,
rows: 14,
colWidth: (col, total) => {
const t = col / total;
return 0.5 + Math.sin(t * Math.PI) * 1.2;
},
rowHeight: (row, total) => {
const t = row / total;
return 0.5 + Math.sin(t * Math.PI) * 1.2;
},
},
{
name: "UNIFORM",
color: "#1B2B5A",
cols: 12,
rows: 12,
colWidth: () => 1,
rowHeight: () => 1,
},
];
interface Cell {
x: number;
y: number;
w: number;
h: number;
filled: boolean;
}
export default function RectGrid() {
const [distIndex, setDistIndex] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const [dimensions, setDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const stageRef = useRef<HTMLDivElement>(null);
// Size against the stage container (the viewport standalone, the window
// when embedded in the GIRAGIRA HQ desktop).
useEffect(() => {
const el = stageRef.current;
if (!el) return;
const update = () =>
setDimensions({ width: el.clientWidth, height: el.clientHeight });
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const dist = distortions[distIndex];
const cells = useMemo(() => {
if (!dimensions) return [];
const { width, height } = dimensions;
const padding = Math.min(width, height) * 0.08;
const availW = width - padding * 2;
const availH = height - padding * 2;
// Compute raw widths and heights
const rawColWidths = Array.from({ length: dist.cols }, (_, i) =>
dist.colWidth(i, dist.cols)
);
const rawRowHeights = Array.from({ length: dist.rows }, (_, i) =>
dist.rowHeight(i, dist.rows)
);
// Normalize to fit available space
const totalRawW = rawColWidths.reduce((a, b) => a + b, 0);
const totalRawH = rawRowHeights.reduce((a, b) => a + b, 0);
const colWidths = rawColWidths.map((w) => (w / totalRawW) * availW);
const rowHeights = rawRowHeights.map((h) => (h / totalRawH) * availH);
// Build cells
const result: Cell[] = [];
let y = padding;
for (let row = 0; row < dist.rows; row++) {
let x = padding;
for (let col = 0; col < dist.cols; col++) {
result.push({
x,
y,
w: colWidths[col],
h: rowHeights[row],
filled: (row + col) % 2 === 0,
});
x += colWidths[col];
}
y += rowHeights[row];
}
return result;
}, [dist, dimensions]);
const handleClick = useCallback(() => {
if (isTransitioning) return;
setIsTransitioning(true);
setDistIndex((prev) => (prev + 1) % distortions.length);
setTimeout(() => setIsTransitioning(false), 1000);
}, [isTransitioning]);
// The stage div must always mount — the ResizeObserver effect measures it,
// so returning null before `dimensions` exists would deadlock the measure.
return (
<div
ref={stageRef}
className="fixed inset-0 cursor-pointer select-none"
style={{ backgroundColor: "#f0eeeb" }}
onClick={handleClick}
>
{cells.map((cell, i) => (
<motion.div
key={i}
initial={false}
animate={{
left: cell.x,
top: cell.y,
width: cell.w,
height: cell.h,
}}
transition={{
type: "spring",
stiffness: 60,
damping: 18,
mass: 0.6,
delay: (i / cells.length) * 0.3,
}}
className="absolute"
style={{
backgroundColor: cell.filled ? dist.color : "#f0eeeb",
}}
/>
))}
{/* UI overlay */}
<div className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]" style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}>
<motion.div
key={dist.name}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<p className="text-[12px] tracking-[0.08em] text-foreground/40 uppercase">
{dist.name}
</p>
<p className="text-[12px] tracking-[0.2em] text-foreground/25 mt-1">
{distIndex + 1} / {distortions.length}
</p>
</motion.div>
</div>
<div className="fixed bottom-8 right-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]" style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}>
<p className="text-[12px] tracking-[0.2em] text-foreground/25 uppercase">
Click anywhere
</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>
);
}