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