NEMAWASHI LAB

Moire

SVG

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.

INTERFERENCE

1 / 5

Click anywhere

Source

MoireCanvas.tsx346 lines
"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>
  );
}

NEMAWASHI — Kotaro Abe