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