NEMAWASHI LAB

Echo

SVG

Two copies of the same text are rendered as semi-transparent SVG layers that slide apart in opposite directions as the mouse moves (normalised cursor position times a maxOffset of 20-35 SVG units). Where they overlap, a third text element -- clipped to the shape of the first layer -- shows through in a contrasting accent colour, so the intersection zone shifts with the cursor. One composition uses dotted strokes instead of fills, making the overlap feel like a registration-mark misalignment rather than a solid colour blend.

GIRAGIRAGIRAGIRAGIRAGIRAGIRAGIRA

GIRAGIRA

1 / 5

Drag to shift

Source

EchoType.tsx368 lines
"use client";

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

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

interface EchoComposition {
  name: string;
  text: string;
  fontSize: number;
  fontVariation: string;
  bg: string;
  color: string;
  intersectColor: string;
  uiColor: "light" | "dark";
  maxOffset: number;
  layerOpacity: number;
  yNudge?: number;
  dotted?: { strokeWidth: number; dash: string };
}

const VB = 1000;

const compositions: EchoComposition[] = [
  {
    name: "GIRAGIRA",
    text: "GIRAGIRA",
    fontSize: 155,
    fontVariation: "'wght' 800, 'SRIF' 400",
    bg: "#f0eeeb",
    color: "#1a1a1a",
    intersectColor: "#2a6abf",
    uiColor: "dark",
    maxOffset: 30,
    layerOpacity: 0.35,
  },
  {
    name: "Glare",
    text: "Glare",
    fontSize: 240,
    fontVariation: "'wght' 800, 'SRIF' 400",
    bg: "#1a1a1a",
    color: "#f0eeeb",
    intersectColor: "#e6c820",
    uiColor: "light",
    maxOffset: 35,
    layerOpacity: 0.35,
  },
  {
    name: "giragira",
    text: "giragira",
    fontSize: 145,
    fontVariation: "'wght' 700, 'SRIF' 300",
    bg: "#e6e0f0",
    color: "#3a3a6a",
    intersectColor: "#d42030",
    uiColor: "dark",
    maxOffset: 25,
    layerOpacity: 0.35,
  },
  {
    name: "G",
    text: "G",
    fontSize: 620,
    fontVariation: "'wght' 800, 'SRIF' 400",
    bg: "#f0eeeb",
    color: "#888888",
    intersectColor: "#2a6abf",
    uiColor: "dark",
    maxOffset: 35,
    layerOpacity: 0.1,
    yNudge: 40,
    dotted: { strokeWidth: 1, dash: "3 3" },
  },
  {
    name: "GIRAGIRA LAB",
    text: "GIRAGIRA LAB",
    fontSize: 105,
    fontVariation: "'wght' 600, 'SRIF' 400",
    bg: "#e0ecf4",
    color: "#2a4a6a",
    intersectColor: "#e6c820",
    uiColor: "dark",
    maxOffset: 20,
    layerOpacity: 0.35,
  },
];

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export default function EchoType() {
  const [index, setIndex] = useState(0);
  const layer1Ref = useRef<SVGTextElement>(null);
  const layer2Ref = useRef<SVGTextElement>(null);
  const clipTextRef = useRef<SVGTextElement>(null);
  const intersectRef = useRef<SVGTextElement>(null);
  const svgRef = useRef<SVGSVGElement>(null);

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

  // Resolve font family from CSS variable
  const [fontFamily, setFontFamily] = useState("sans-serif");
  useEffect(() => {
    const ff =
      getComputedStyle(document.documentElement)
        .getPropertyValue("--font-kt-flux")
        .trim() || "sans-serif";
    setFontFamily(ff);
  }, []);

  // Mouse / touch → split the two layers apart
  useEffect(() => {
    function applyOffset(clientX: number, clientY: number) {
      const svg = svgRef.current;
      const t1 = layer1Ref.current;
      const t2 = layer2Ref.current;
      const ct = clipTextRef.current;
      const it = intersectRef.current;
      if (!svg || !t1 || !t2) return;

      const rect = svg.getBoundingClientRect();
      const nx = (clientX - rect.left) / rect.width * 2 - 1;
      const ny = (clientY - rect.top) / rect.height * 2 - 1;

      const ox = nx * comp.maxOffset;
      const oy = ny * comp.maxOffset;

      t1.setAttribute("transform", `translate(${-ox}, ${-oy})`);
      t2.setAttribute("transform", `translate(${ox}, ${oy})`);
      if (ct) ct.setAttribute("transform", `translate(${-ox}, ${-oy})`);
      if (it) it.setAttribute("transform", `translate(${ox}, ${oy})`);
    }

    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.maxOffset]);

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

  // Export SVG
  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");

      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 = `echo-${comp.name}.svg`;
      a.click();
      URL.revokeObjectURL(url);
    },
    [comp.bg, comp.name]
  );

  const textY = VB / 2 + (comp.yNudge ?? 0);

  const textStyle = {
    fontSize: comp.fontSize,
    fontFamily,
    fontVariationSettings: comp.fontVariation,
  };

  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"
      >
        <defs>
          {/* Clip shape = layer 1's text, tracks its offset */}
          <clipPath id={`echo-clip-${index}`}>
            <text
              ref={clipTextRef}
              x={VB / 2}
              y={textY}
              textAnchor="middle"
              dominantBaseline="central"
              style={textStyle}
            >
              {comp.text}
            </text>
          </clipPath>
        </defs>

        {/* Ghost layer 1 */}
        <text
          ref={layer1Ref}
          x={VB / 2}
          y={textY}
          textAnchor="middle"
          dominantBaseline="central"
          fill={comp.color}
          fillOpacity={comp.dotted ? comp.layerOpacity : undefined}
          stroke={comp.dotted ? comp.color : undefined}
          strokeWidth={comp.dotted?.strokeWidth}
          strokeDasharray={comp.dotted?.dash}
          strokeOpacity={comp.dotted ? 0.5 : undefined}
          opacity={comp.dotted ? undefined : comp.layerOpacity}
          style={textStyle}
        >
          {comp.text}
        </text>

        {/* Ghost layer 2 */}
        <text
          ref={layer2Ref}
          x={VB / 2}
          y={textY}
          textAnchor="middle"
          dominantBaseline="central"
          fill={comp.color}
          fillOpacity={comp.dotted ? comp.layerOpacity : undefined}
          stroke={comp.dotted ? comp.color : undefined}
          strokeWidth={comp.dotted?.strokeWidth}
          strokeDasharray={comp.dotted?.dash}
          strokeOpacity={comp.dotted ? 0.5 : undefined}
          opacity={comp.dotted ? undefined : comp.layerOpacity}
          style={textStyle}
        >
          {comp.text}
        </text>

        {/* Intersection: layer 2 text clipped by layer 1 shape */}
        <g clipPath={`url(#echo-clip-${index})`}>
          <text
            ref={intersectRef}
            x={VB / 2}
            y={textY}
            textAnchor="middle"
            dominantBaseline="central"
            fill={comp.intersectColor}
            opacity={0.85}
            style={textStyle}
          >
            {comp.text}
          </text>
        </g>
      </svg>

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

      {/* 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 shift
        </motion.p>
      </div>

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