NEMAWASHI LAB

Moire Type

SVG

Two identical dot grids are rendered as SVG circles inside a 1000x1000 viewBox -- one grid has dots displaced wherever an offscreen canvas detects text-shaped pixels (alpha > 128), the other stays uniform and tracks the mouse with a tiny sensitivity multiplier (~0.015). The overlap produces a moire interference pattern that reveals the hidden letterforms as the cursor drifts. Clicking cycles through compositions that vary dot spacing, displacement vector, colour, and radius, so each preset reads as a different typographic texture.

Source

MoireType.tsx389 lines
"use client";

import { useEffect, useRef, useState, useCallback } from "react";
import { motion } from "framer-motion";
import ScrambleLink from "../ScrambleLink";

// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------

interface Composition {
  name: string;
  text: string;
  fontSize: number;
  fontVariation: string;
  bg: string;
  dotColor: string;
  uiColor: "light" | "dark";
  spacing: number;
  dotRadius: number;
  displace: [number, number];
  sensitivity: number;
  maskOpacity: number;
}

const VB = 1000;

const compositions: Composition[] = [
  {
    name: "GIRAGIRA",
    text: "GIRAGIRA",
    fontSize: 175,
    fontVariation: "'wght' 800, 'SRIF' 400",
    bg: "#f0eeeb",
    dotColor: "#9a9ec8",
    uiColor: "dark",
    spacing: 11,
    dotRadius: 3,
    displace: [4, 0],
    sensitivity: 0.015,
    maskOpacity: 0.5,
  },
  {
    name: "giragira",
    text: "giragira",
    fontSize: 165,
    fontVariation: "'wght' 700, 'SRIF' 300",
    bg: "#e8e2f0",
    dotColor: "#5a4aad",
    uiColor: "dark",
    spacing: 14,
    dotRadius: 4.5,
    displace: [0, 3.5],
    sensitivity: 0.012,
    maskOpacity: 0.5,
  },
  {
    name: "Glare",
    text: "Glare",
    fontSize: 255,
    fontVariation: "'wght' 800, 'SRIF' 400",
    bg: "#f5ede0",
    dotColor: "#c86040",
    uiColor: "dark",
    spacing: 10,
    dotRadius: 2.8,
    displace: [3.5, 3.5],
    sensitivity: 0.018,
    maskOpacity: 0.45,
  },
  {
    name: "GIRAGIRA LAB",
    text: "GIRAGIRA LAB",
    fontSize: 120,
    fontVariation: "'wght' 600, 'SRIF' 400",
    bg: "#f0eeeb",
    dotColor: "#2a6a5a",
    uiColor: "dark",
    spacing: 13,
    dotRadius: 4,
    displace: [-4, 0],
    sensitivity: 0.015,
    maskOpacity: 0.5,
  },
];

// ---------------------------------------------------------------------------
// Grid computation (offscreen canvas used only for hit-testing — output is SVG)
// ---------------------------------------------------------------------------

interface GridData {
  base: [number, number][];
  mask: [number, number][];
}

function computeGrids(comp: Composition): GridData {
  // Resolve the KT Flux font-family name from the CSS variable
  const fontFamily =
    getComputedStyle(document.documentElement)
      .getPropertyValue("--font-kt-flux")
      .trim() || "sans-serif";

  const canvas = document.createElement("canvas");
  canvas.width = VB;
  canvas.height = VB;
  const ctx = canvas.getContext("2d")!;

  ctx.font = `${comp.fontSize}px ${fontFamily}`;
  // Apply variable font axes (wght, SRIF) for hit-testing
  (ctx as unknown as { fontVariationSettings: string }).fontVariationSettings =
    comp.fontVariation;
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillStyle = "#000";
  ctx.fillText(comp.text, VB / 2, VB / 2);

  const { data } = ctx.getImageData(0, 0, VB, VB);

  const cols = Math.floor(VB / comp.spacing);
  const rows = Math.floor(VB / comp.spacing);
  const ox = (VB - (cols - 1) * comp.spacing) / 2;
  const oy = (VB - (rows - 1) * comp.spacing) / 2;

  const base: [number, number][] = new Array(rows * cols);
  const mask: [number, number][] = new Array(rows * cols);

  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      const x = ox + c * comp.spacing;
      const y = oy + r * comp.spacing;
      const i = r * cols + c;

      mask[i] = [x, y];

      const px = Math.round(x);
      const py = Math.round(y);
      const inside =
        px >= 0 &&
        px < VB &&
        py >= 0 &&
        py < VB &&
        data[(py * VB + px) * 4 + 3] > 128;

      base[i] = inside
        ? [x + comp.displace[0], y + comp.displace[1]]
        : [x, y];
    }
  }

  return { base, mask };
}

// ---------------------------------------------------------------------------
// Static circle layer — never re-renders after mount
// ---------------------------------------------------------------------------

function CircleLayer({
  circles,
  color,
  radius,
  opacity,
  groupRef,
}: {
  circles: [number, number][];
  color: string;
  radius: number;
  opacity?: number;
  groupRef?: React.Ref<SVGGElement>;
}) {
  return (
    <g ref={groupRef} fill={color} opacity={opacity}>
      {circles.map(([cx, cy], i) => (
        <circle key={i} cx={cx} cy={cy} r={radius} />
      ))}
    </g>
  );
}

// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------

export default function MoireType() {
  const [index, setIndex] = useState(0);
  const [gridData, setGridData] = useState<GridData | null>(null);

  const maskRef = useRef<SVGGElement>(null);
  const svgRef = useRef<SVGSVGElement>(null);

  const comp = compositions[index];
  const isLight = comp.uiColor === "light";

  // Compute grids on composition change (client-only)
  useEffect(() => {
    setGridData(computeGrids(comp));
  }, [comp]);

  // Mouse / touch → mask transform (direct DOM, no React re-render)
  useEffect(() => {
    function applyOffset(clientX: number, clientY: number) {
      const svg = svgRef.current;
      const mask = maskRef.current;
      if (!svg || !mask) return;

      const rect = svg.getBoundingClientRect();
      const mx = ((clientX - rect.left) / rect.width - 0.5) * VB;
      const my = ((clientY - rect.top) / rect.height - 0.5) * VB;

      mask.setAttribute(
        "transform",
        `translate(${mx * comp.sensitivity}, ${my * comp.sensitivity})`
      );
    }

    function onMove(e: MouseEvent) {
      applyOffset(e.clientX, e.clientY);
    }
    function onTouch(e: TouchEvent) {
      if (e.touches.length > 0) {
        applyOffset(e.touches[0].clientX, e.touches[0].clientY);
      }
    }

    window.addEventListener("mousemove", onMove);
    window.addEventListener("touchmove", onTouch, { passive: true });
    return () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("touchmove", onTouch);
    };
  }, [comp.sensitivity]);

  // Cycle compositions
  const handleClick = useCallback(() => {
    setIndex((prev) => (prev + 1) % compositions.length);
  }, []);

  // Export current SVG frame
  const handleExport = useCallback(
    (e: React.MouseEvent) => {
      e.stopPropagation();
      const svg = svgRef.current;
      if (!svg) return;

      const clone = svg.cloneNode(true) as SVGSVGElement;
      clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");

      // Bake the current background into the SVG
      const bgRect = document.createElementNS(
        "http://www.w3.org/2000/svg",
        "rect"
      );
      bgRect.setAttribute("width", String(VB));
      bgRect.setAttribute("height", String(VB));
      bgRect.setAttribute("fill", comp.bg);
      clone.insertBefore(bgRect, clone.firstChild);

      const blob = new Blob(
        [new XMLSerializer().serializeToString(clone)],
        { type: "image/svg+xml" }
      );
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `moire-type-${comp.name}.svg`;
      a.click();
      URL.revokeObjectURL(url);
    },
    [comp.bg, comp.name]
  );

  // Loading state
  if (!gridData) {
    return (
      <div className="fixed inset-0" style={{ backgroundColor: comp.bg }} />
    );
  }

  return (
    <div
      className="fixed inset-0 cursor-crosshair select-none transition-colors duration-700"
      style={{ backgroundColor: comp.bg }}
      onClick={handleClick}
    >
      <svg
        ref={svgRef}
        className="absolute inset-0 w-full h-full"
        viewBox={`0 0 ${VB} ${VB}`}
        preserveAspectRatio="xMidYMid slice"
        overflow="hidden"
      >
        {/* Base grid — dots inside hidden text are displaced */}
        <CircleLayer
          circles={gridData.base}
          color={comp.dotColor}
          radius={comp.dotRadius}
        />

        {/* Mask grid — uniform, follows mouse */}
        <CircleLayer
          circles={gridData.mask}
          color={comp.dotColor}
          radius={comp.dotRadius}
          opacity={comp.maskOpacity}
          groupRef={maskRef}
        />
      </svg>

      {/* Export button */}
      <button
        onClick={handleExport}
        className="fixed top-8 right-8 z-10 pointer-events-auto font-[family-name:var(--font-flux)] text-[12px] tracking-[0.08em] uppercase transition-opacity hover:opacity-100"
        style={{
          fontVariationSettings: "'wght' 300, 'SRIF' 100",
          color: isLight ? "rgba(255,255,255,0.4)" : "rgba(23,23,23,0.4)",
        }}
      >
        Export SVG
      </button>

      {/* Composition label */}
      <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>

      {/* Hint */}
      <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 }}
        >
          Drag to reveal
        </motion.p>
      </div>

      {/* Back link */}
      <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