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