Multiple dot grids rotate independently on a rAF loop, each at its own angular velocity, producing shifting moire interference bands in real time. Five preset compositions cycle on click, varying dot shape (circle, plus, minus, triangle), grid density, per-layer clip masks (square, circle, triangle), speed, and palette. The visual complexity arises purely from overlapping regular lattices -- no blending or compositing tricks.
Source
"use client";
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";
type DotShape = "circle" | "plus" | "minus" | "triangle";
type ClipShape = "square" | "circle" | "triangle";
interface LayerConfig {
speed: number;
initialAngle: number;
cx: number;
cy: number;
clip?: ClipShape;
clipSize?: number;
}
interface MoireComposition {
name: string;
bg: string;
dotColor: string;
dotOpacity: number;
uiColor: "light" | "dark";
gridSize: number;
dotRadius: number;
spacing: number;
dotShape?: DotShape;
layers: LayerConfig[];
}
const compositions: MoireComposition[] = [
{
name: "INTERFERENCE",
bg: "#e6e0f0",
dotColor: "#3a3a6a",
dotOpacity: 0.5,
uiColor: "dark",
gridSize: 65,
dotRadius: 2.5,
spacing: 11,
layers: [
{ speed: 1.5, initialAngle: 0, cx: 350, cy: 280, clip: "square", clipSize: 280 },
{ speed: -2, initialAngle: 20, cx: 380, cy: 620, clip: "triangle", clipSize: 300 },
{ speed: 2.5, initialAngle: 45, cx: 620, cy: 350, clip: "circle", clipSize: 260 },
{ speed: -1.8, initialAngle: 10, cx: 600, cy: 650, clip: "square", clipSize: 250 },
],
},
{
name: "BINARY",
bg: "#1a1a1a",
dotColor: "#e8e4e0",
dotOpacity: 0.35,
uiColor: "light",
gridSize: 16,
dotRadius: 7,
spacing: 26,
dotShape: "plus",
layers: [
{ speed: 1, initialAngle: 0, cx: 300, cy: 300 },
{ speed: -1.5, initialAngle: 30, cx: 600, cy: 350 },
{ speed: 2, initialAngle: 60, cx: 450, cy: 600 },
{ speed: -2.5, initialAngle: 15, cx: 700, cy: 650 },
],
},
{
name: "TRIAD",
bg: "#f0e4e0",
dotColor: "#6a2020",
dotOpacity: 0.4,
uiColor: "dark",
gridSize: 12,
dotRadius: 7,
spacing: 34,
dotShape: "triangle",
layers: [
{ speed: 1.2, initialAngle: 0, cx: 350, cy: 250 },
{ speed: -2, initialAngle: 30, cx: 650, cy: 350 },
{ speed: 3, initialAngle: 60, cx: 400, cy: 550 },
{ speed: -1.5, initialAngle: 45, cx: 600, cy: 700 },
],
},
{
name: "PULSE",
bg: "#e0ecf4",
dotColor: "#2a4a6a",
dotOpacity: 0.45,
uiColor: "dark",
gridSize: 13,
dotRadius: 7,
spacing: 32,
dotShape: "plus",
layers: [
{ speed: 0.8, initialAngle: 0, cx: 280, cy: 300 },
{ speed: -1.2, initialAngle: 20, cx: 520, cy: 250 },
{ speed: 2, initialAngle: 45, cx: 700, cy: 450 },
{ speed: -1.8, initialAngle: -10, cx: 400, cy: 600 },
{ speed: 1.5, initialAngle: 70, cx: 600, cy: 750 },
],
},
{
name: "STATIC",
bg: "#e2e8e0",
dotColor: "#2a4a2a",
dotOpacity: 0.25,
uiColor: "dark",
gridSize: 15,
dotRadius: 7,
spacing: 28,
dotShape: "minus",
layers: [
{ speed: 0.3, initialAngle: 0, cx: 350, cy: 350 },
{ speed: -0.5, initialAngle: 45, cx: 650, cy: 500 },
{ speed: 0.4, initialAngle: 20, cx: 400, cy: 700 },
],
},
];
function DotGrid({
gridSize,
dotRadius,
spacing,
dotShape = "circle",
}: {
gridSize: number;
dotRadius: number;
spacing: number;
dotShape?: DotShape;
}) {
const elements = useMemo(() => {
const result = [];
const half = ((gridSize - 1) * spacing) / 2;
const r = dotRadius;
const t = r * 0.4;
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < gridSize; col++) {
const x = -half + col * spacing;
const y = -half + row * spacing;
if (dotShape === "circle") {
result.push(<circle key={`${row}-${col}`} cx={x} cy={y} r={r} />);
} else if (dotShape === "plus") {
result.push(
<g key={`${row}-${col}`}>
<rect x={x - r} y={y - t} width={r * 2} height={t * 2} />
<rect x={x - t} y={y - r} width={t * 2} height={r * 2} />
</g>
);
} else if (dotShape === "minus") {
result.push(
<rect
key={`${row}-${col}`}
x={x - r}
y={y - t}
width={r * 2}
height={t * 2}
/>
);
} else {
const h = r * 1.1;
result.push(
<polygon
key={`${row}-${col}`}
points={`${x},${y - h} ${x - r},${y + h * 0.5} ${x + r},${y + h * 0.5}`}
/>
);
}
}
}
return result;
}, [gridSize, dotRadius, spacing, dotShape]);
return <>{elements}</>;
}
export default function MoireCanvas() {
const [index, setIndex] = useState(0);
const layerRefs = useRef<(SVGGElement | null)[]>([]);
const rafRef = useRef<number>(0);
const startTimeRef = useRef<number>(0);
const comp = compositions[index];
const isLight = comp.uiColor === "light";
const gridHalf =
((comp.gridSize - 1) * comp.spacing) / 2 + comp.dotRadius;
useEffect(() => {
startTimeRef.current = performance.now();
function animate(now: number) {
const elapsed = (now - startTimeRef.current) / 1000;
comp.layers.forEach((layer, i) => {
const el = layerRefs.current[i];
if (el) {
const angle = layer.initialAngle + layer.speed * elapsed;
el.setAttribute(
"transform",
`translate(${layer.cx}, ${layer.cy}) rotate(${angle})`
);
}
});
rafRef.current = requestAnimationFrame(animate);
}
rafRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(rafRef.current);
}, [comp]);
const handleClick = useCallback(() => {
setIndex((prev) => (prev + 1) % compositions.length);
}, []);
return (
<div
className="fixed inset-0 cursor-pointer select-none transition-colors duration-700"
style={{ backgroundColor: comp.bg }}
onClick={handleClick}
>
<svg
className="absolute inset-0 w-full h-full"
viewBox="0 0 1000 1000"
preserveAspectRatio="xMidYMid slice"
overflow="hidden"
>
<defs>
{comp.layers.map((layer, i) => {
const clip = layer.clip ?? "square";
const s = layer.clipSize ?? gridHalf;
return (
<clipPath key={`clip-${index}-${i}`} id={`clip-${index}-${i}`}>
{clip === "square" && (
<rect
x={-s}
y={-s}
width={s * 2}
height={s * 2}
/>
)}
{clip === "circle" && (
<circle cx={0} cy={0} r={s} />
)}
{clip === "triangle" && (
<polygon
points={`0,${-s} ${-s},${s} ${s},${s}`}
/>
)}
</clipPath>
);
})}
</defs>
{comp.layers.map((layer, i) => (
<g
key={`${index}-${i}`}
ref={(el) => {
layerRefs.current[i] = el;
}}
transform={`translate(${layer.cx}, ${layer.cy}) rotate(${layer.initialAngle})`}
>
<g
clipPath={`url(#clip-${index}-${i})`}
fill={comp.dotColor}
opacity={1}
>
<DotGrid
gridSize={comp.gridSize}
dotRadius={comp.dotRadius}
spacing={comp.spacing}
dotShape={comp.dotShape}
/>
</g>
</g>
))}
</svg>
<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={comp.name}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<motion.p
className="text-[12px] tracking-[0.08em] uppercase"
animate={{
color: isLight
? "rgba(255,255,255,0.5)"
: "rgba(23,23,23,0.5)",
}}
transition={{ duration: 0.6 }}
>
{comp.name}
</motion.p>
<motion.p
className="text-[12px] tracking-[0.08em] mt-1"
animate={{
color: isLight
? "rgba(255,255,255,0.3)"
: "rgba(23,23,23,0.3)",
}}
transition={{ duration: 0.6 }}
>
{index + 1} / {compositions.length}
</motion.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" }}
>
<motion.p
className="text-[12px] tracking-[0.08em] uppercase"
animate={{
color: isLight
? "rgba(255,255,255,0.3)"
: "rgba(23,23,23,0.3)",
}}
transition={{ duration: 0.6 }}
>
Click anywhere
</motion.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] uppercase pointer-events-auto font-[family-name:var(--font-flux)] ${
isLight ? "text-white/60" : "text-foreground/60"
}`}
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
/>
</div>
</div>
);
}