NEMAWASHI LAB

Ribbon

SVG

Text set along a thick SVG stroke path (90px wide) that spirals, zigzags, or serpentines through the viewport. The path data for each composition has the same command structure (M + 12 cubics), so clicking interpolates every coordinate with easeInOutCubic over one second while the text fades out at 35%, swaps content at 50%, and fades back in at 65%. The ribbon colour and background lerp in parallel, making the whole transition feel like a single continuous gesture.

GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA GIRAGIRA

GIRAGIRA

1 / 6

Click anywhere

Source

RibbonCanvas.tsx352 lines
"use client";

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

interface RibbonComposition {
  name: string;
  text: string;
  ribbonColor: string;
  textColor: string;
  bg: string;
  uiColor: "light" | "dark";
  /**
   * Single path: M + 12C (74 numbers).
   * All paths have C1 tangent continuity at every junction
   * for smooth thick-stroke rendering.
   */
  path: string;
}

const RIBBON_WIDTH = 90;
const FONT_SIZE = 42;

const compositions: RibbonComposition[] = [
  {
    name: "GIRAGIRA",
    text: "GIRAGIRA ",
    ribbonColor: "#1a1a1a",
    textColor: "#f0eeeb",
    bg: "#f0eeeb",
    uiColor: "dark",
    // Circular spiral — entry is a proper spiral arc from bottom
    path: "M 500 996 C 226 996 40 754 40 500 C 40 246 266 76 500 76 C 734 76 888 286 888 500 C 888 714 695 853 500 853 C 305 853 183 675 183 500 C 183 325 345 219 500 219 C 655 219 745 365 745 500 C 745 635 616 709 500 709 C 384 709 327 596 327 500 C 327 404 424 363 500 363 C 576 363 602 444 602 500 C 602 556 536 566 500 566",
  },
  {
    name: "RECTANGLE",
    text: "RECTANGLES ",
    ribbonColor: "#2a4a6a",
    textColor: "#e0ecf4",
    bg: "#e0ecf4",
    uiColor: "dark",
    // Rectangular spiral — tight corners
    path: "M 500 996 C 441 996 40 555 40 500 C 40 445 449 76 500 76 C 551 76 888 453 888 500 C 888 547 542 853 500 853 C 458 853 183 538 183 500 C 183 462 466 219 500 219 C 534 219 745 471 745 500 C 745 529 525 709 500 709 C 475 709 327 521 327 500 C 327 479 484 363 500 363 C 517 363 602 488 602 500 C 602 512 508 566 500 566",
  },
  {
    name: "ZIGZAG",
    text: "ZIGZAG ZIGZAG ",
    ribbonColor: "#e8e4e0",
    textColor: "#1a1a1a",
    bg: "#1a1a1a",
    uiColor: "light",
    // Snake pattern — S-curves alternating left-right
    path: "M 500 1037 C 500 1003 900 995 900 960 C 900 926 100 918 100 883 C 100 849 900 842 900 807 C 900 773 100 765 100 730 C 100 696 900 688 900 653 C 900 619 100 612 100 577 C 100 543 900 535 900 500 C 900 466 100 458 100 423 C 100 389 900 382 900 347 C 900 313 100 305 100 270 C 100 236 900 228 900 193 C 900 159 100 152 100 117",
  },
  {
    name: "HEXAGON",
    text: "HEXAGON HEX ",
    ribbonColor: "#3a3a6a",
    textColor: "#e6e0f0",
    bg: "#e6e0f0",
    uiColor: "dark",
    // Hexagonal spiral — rounder corners
    path: "M 500 996 C 322 996 40 666 40 500 C 40 334 347 76 500 76 C 653 76 888 360 888 500 C 888 640 627 853 500 853 C 373 853 183 614 183 500 C 183 386 399 219 500 219 C 601 219 745 412 745 500 C 745 588 575 709 500 709 C 425 709 327 562 327 500 C 327 438 451 363 500 363 C 550 363 602 463 602 500 C 602 537 524 566 500 566",
  },
  {
    name: "TRIANGLE",
    text: "TRIANGLES ",
    ribbonColor: "#6a2020",
    textColor: "#f0e4e0",
    bg: "#f0e4e0",
    uiColor: "dark",
    // Triangular spiral — wider arcs
    path: "M 500 996 C 118 996 40 854 40 500 C 40 146 173 76 500 76 C 827 76 888 201 888 500 C 888 799 771 853 500 853 C 229 853 183 744 183 500 C 183 256 284 219 500 219 C 716 219 745 311 745 500 C 745 689 661 709 500 709 C 339 709 327 633 327 500 C 327 367 394 363 500 363 C 606 363 602 422 602 500 C 602 578 551 566 500 566",
  },
  {
    name: "WAVE",
    text: "WAVE WAVE WAVE ",
    ribbonColor: "#2a4a2a",
    textColor: "#e2e8e0",
    bg: "#e2e8e0",
    uiColor: "dark",
    // Sine wave — undulating vertically
    path: "M 500 1037 C 500 1003 850 995 850 960 C 850 926 150 918 150 883 C 150 849 850 842 850 807 C 850 773 150 765 150 730 C 150 696 850 688 850 653 C 850 619 150 612 150 577 C 150 543 850 535 850 500 C 850 466 150 458 150 423 C 150 389 850 382 850 347 C 850 313 150 305 150 270 C 150 236 850 228 850 193 C 850 159 150 152 150 117",
  },
];

// --- Path interpolation utilities ---

function parsePathNumbers(d: string): number[] {
  return (d.match(/-?\d+(\.\d+)?/g) || []).map(Number);
}

function buildPathFromNumbers(template: string, numbers: number[]): string {
  let i = 0;
  return template.replace(/-?\d+(\.\d+)?/g, () => String(Math.round(numbers[i++])));
}

function hexToRgb(hex: string): [number, number, number] {
  const h = hex.replace("#", "");
  return [
    parseInt(h.substring(0, 2), 16),
    parseInt(h.substring(2, 4), 16),
    parseInt(h.substring(4, 6), 16),
  ];
}

function rgbToHex(r: number, g: number, b: number): string {
  return (
    "#" +
    [r, g, b]
      .map((v) =>
        Math.round(Math.min(255, Math.max(0, v)))
          .toString(16)
          .padStart(2, "0"),
      )
      .join("")
  );
}

function lerpColor(a: string, b: string, t: number): string {
  const [r1, g1, b1] = hexToRgb(a);
  const [r2, g2, b2] = hexToRgb(b);
  return rgbToHex(
    r1 + (r2 - r1) * t,
    g1 + (g2 - g1) * t,
    b1 + (b2 - b1) * t,
  );
}

function easeInOutCubic(t: number): number {
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

const parsedPaths = compositions.map((c) => parsePathNumbers(c.path));

const colorTransition = {
  duration: 0.6,
  ease: "easeInOut" as const,
};

function getInitialIndex() {
  if (typeof window === "undefined") return 0;
  const p = new URLSearchParams(window.location.search);
  const i = parseInt(p.get("i") || "0", 10);
  return isNaN(i) ? 0 : i % compositions.length;
}

export default function RibbonCanvas() {
  const [index, setIndex] = useState(getInitialIndex);
  const [displayText, setDisplayText] = useState(() =>
    compositions[getInitialIndex()].text.repeat(60),
  );
  const animatingRef = useRef(false);
  const rafRef = useRef<number>(0);

  const containerRef = useRef<HTMLDivElement>(null);
  const ribbonRef = useRef<SVGPathElement>(null);
  const textPathRef = useRef<SVGPathElement>(null);
  const textRef = useRef<SVGTextPathElement>(null);

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

  const handleClick = useCallback(() => {
    if (animatingRef.current) return;
    animatingRef.current = true;

    const fromIndex = index;
    const toIndex = (fromIndex + 1) % compositions.length;
    const fromComp = compositions[fromIndex];
    const toComp = compositions[toIndex];
    const fromNums = parsedPaths[fromIndex];
    const toNums = parsedPaths[toIndex];

    const duration = 1000;
    const start = performance.now();
    let textSwapped = false;

    function step(now: number) {
      const elapsed = now - start;
      const rawT = Math.min(elapsed / duration, 1);
      const t = easeInOutCubic(rawT);

      const interpolated = fromNums.map((v, j) => v + (toNums[j] - v) * t);
      const d = buildPathFromNumbers(fromComp.path, interpolated);
      ribbonRef.current?.setAttribute("d", d);
      textPathRef.current?.setAttribute("d", d);

      ribbonRef.current?.setAttribute(
        "stroke",
        lerpColor(fromComp.ribbonColor, toComp.ribbonColor, t),
      );

      textRef.current?.setAttribute(
        "fill",
        lerpColor(fromComp.textColor, toComp.textColor, t),
      );

      let textOpacity: number;
      if (rawT < 0.35) {
        textOpacity = 1 - rawT / 0.35;
      } else if (rawT < 0.65) {
        textOpacity = 0;
      } else {
        textOpacity = (rawT - 0.65) / 0.35;
      }
      textRef.current?.setAttribute("opacity", String(textOpacity));

      if (!textSwapped && rawT >= 0.5) {
        textSwapped = true;
        if (textRef.current) {
          textRef.current.textContent = toComp.text.repeat(60);
        }
      }

      if (containerRef.current) {
        containerRef.current.style.backgroundColor = lerpColor(
          fromComp.bg,
          toComp.bg,
          t,
        );
      }

      if (rawT < 1) {
        rafRef.current = requestAnimationFrame(step);
      } else {
        setDisplayText(toComp.text.repeat(60));
        setIndex(toIndex);
        animatingRef.current = false;
      }
    }

    rafRef.current = requestAnimationFrame(step);
  }, [index]);

  return (
    <div
      ref={containerRef}
      className="fixed inset-0 cursor-pointer select-none"
      style={{ backgroundColor: comp.bg }}
      onClick={handleClick}
    >
      <svg
        viewBox="0 0 1000 1000"
        className="absolute inset-0 w-full h-full"
        preserveAspectRatio="xMidYMid meet"
        overflow="visible"
      >
        <path
          ref={ribbonRef}
          d={comp.path}
          fill="none"
          stroke={comp.ribbonColor}
          strokeWidth={RIBBON_WIDTH}
          strokeLinecap="round"
          strokeLinejoin="round"
        />
        <text
          fill="none"
          fontSize={FONT_SIZE}
          letterSpacing="0.02em"
          dominantBaseline="central"
          style={{
            fontFamily: "var(--font-kt-flux)",
            fontVariationSettings: "'wght' 600, 'SRIF' 100",
          }}
        >
          <textPath
            ref={textRef}
            href="#ribbon-path"
            startOffset="0%"
            fill={comp.textColor}
          >
            {displayText}
          </textPath>
        </text>
        <path
          ref={textPathRef}
          id="ribbon-path"
          d={comp.path}
          fill="none"
          stroke="none"
        />
      </svg>

      {/* UI overlay */}
      <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={colorTransition}
          >
            {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={colorTransition}
          >
            {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={colorTransition}
        >
          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