Each character in "GIRAGIRA" is an independent inline-block span whose distance to the cursor drives a smoothstepped activation value (0-1). As activation rises, the letter scales up, lifts with an easeOut curve, blurs, and colour-shifts toward a cycling pastel palette (eight-stop gradient sampled with a slow sine + global time offset). Behind the text, a canvas draws soft radial-gradient blobs per character that rise and expand as the letter lifts, all blurred at the canvas level (11 px default). Five presets (Tight & Subtle through Snappy Pop) swap out the full parameter set -- radii, speeds, blur power, colour delay, blob radius -- in one click.
Source
"use client";
import { useEffect, useRef, useCallback, useState } from "react";
import ScrambleLink from "../ScrambleLink";
const PHRASE = "GIRAGIRA";
const FONT_SIZE_VW = 8;
// GRGR palette — pastel/lighter tones
const COLOR_STOPS: [number, number, number, number][] = [
[0.0, 249, 154, 158], // Pastel red #f99a9e
[0.14, 251, 217, 218], // Blush #fbd9da
[0.28, 236, 220, 167], // Pastel gold #ecdca7
[0.42, 240, 191, 163], // Pastel peach #f0bfa3
[0.56, 220, 217, 251], // Pastel lavender #dcd9fb
[0.70, 163, 154, 248], // Pastel violet #a39af8
[0.84, 175, 202, 228], // Pastel blue #afcae4
[1.0, 218, 235, 251], // Ice blue #daebfb
];
function colorAt(t: number): [number, number, number] {
t = Math.max(0, Math.min(1, t));
for (let i = 0; i < COLOR_STOPS.length - 1; i++) {
const [at, ar, ag, ab] = COLOR_STOPS[i];
const [bt, br, bg, bb] = COLOR_STOPS[i + 1];
if (t <= bt) {
const f = (t - at) / (bt - at);
return [
Math.round(ar + (br - ar) * f),
Math.round(ag + (bg - ag) * f),
Math.round(ab + (bb - ab) * f),
];
}
}
const l = COLOR_STOPS[COLOR_STOPS.length - 1];
return [l[1], l[2], l[3]];
}
function hash(i: number) {
let x = Math.sin(i * 127.1 + i * 311.7) * 43758.5453;
return x - Math.floor(x);
}
function smoothstep(lo: number, hi: number, x: number) {
const t = Math.max(0, Math.min(1, (x - lo) / (hi - lo)));
return t * t * (3 - 2 * t);
}
const easeOut = (t: number) => 1 - (1 - t) ** 3;
interface CharBlob {
cx: number;
cy: number;
wx: number;
wy: number;
h: number;
colorPhase: number;
color: [number, number, number];
capR: number;
riseH: number;
a: number;
}
const cfg = {
innerR: 0.05,
outerR: 1.0,
speedIn: 0.07,
speedOut: 0.03,
maxBlur: 2.0,
blurPow: 2.2,
maxScale: 1.08,
scalePow: 4,
lift: 12,
colorDelay: 0.3,
opacityThresh: 0.95,
canvasBlur: 11,
blobDelay: 0.6,
blobRadius: 0.2,
blobLiftFollow: 0.8,
blobRiseMult: 0.25,
blobOpacity: 0.8,
colorSpread: 0.2,
colorSpeed: 0.00013,
};
const PRESETS: Record<string, Partial<typeof cfg>> = {
"Tight & Subtle": { ...cfg },
"Warm Dissolve": {
innerR: 0.2, outerR: 2.5, speedIn: 0.13, speedOut: 0.09,
maxBlur: 11.5, blurPow: 1.2, maxScale: 1.0, scalePow: 4,
lift: 29, colorDelay: 0.55, opacityThresh: 0.9, canvasBlur: 9,
blobDelay: 0.35, blobRadius: 0.45, blobLiftFollow: 1.1,
blobRiseMult: 0.65, blobOpacity: 1.0, colorSpread: 0.1, colorSpeed: 0.00005,
},
"Big & Bold": {
innerR: 1, outerR: 8, speedIn: 0.14, speedOut: 0.05,
maxBlur: 14, blurPow: 1.8, maxScale: 1.35, scalePow: 2,
lift: 40, colorDelay: 0.25, opacityThresh: 0.65, canvasBlur: 8,
blobDelay: 0.3, blobRadius: 0.9, blobLiftFollow: 0.7,
blobRiseMult: 1.0, blobOpacity: 0.95, colorSpread: 0.3, colorSpeed: 0.00012,
},
"Dreamy Slow": {
innerR: 0.8, outerR: 6, speedIn: 0.05, speedOut: 0.02,
maxBlur: 10, blurPow: 2.5, maxScale: 1.15, scalePow: 3,
lift: 22, colorDelay: 0.5, opacityThresh: 0.85, canvasBlur: 12,
blobDelay: 0.5, blobRadius: 0.7, blobLiftFollow: 0.6,
blobRiseMult: 0.9, blobOpacity: 0.75, colorSpread: 0.12, colorSpeed: 0.00004,
},
"Snappy Pop": {
innerR: 0.2, outerR: 3, speedIn: 0.22, speedOut: 0.08,
maxBlur: 6, blurPow: 1.5, maxScale: 1.25, scalePow: 2,
lift: 35, colorDelay: 0.3, opacityThresh: 0.7, canvasBlur: 3,
blobDelay: 0.35, blobRadius: 0.45, blobLiftFollow: 0.85,
blobRiseMult: 0.5, blobOpacity: 0.92, colorSpread: 0.22, colorSpeed: 0.0001,
},
};
const PAD = 200;
export default function ProximityText() {
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const charsRef = useRef<HTMLSpanElement[]>([]);
const blobsRef = useRef<CharBlob[]>([]);
const mouseRef = useRef({ x: 0, y: 0, active: false });
const rafRef = useRef<number | null>(null);
const cfgRef = useRef({ ...cfg });
const [preset, setPreset] = useState("Tight & Subtle");
const init = useCallback(() => {
const wrap = wrapRef.current;
const cvs = canvasRef.current;
if (!wrap || !cvs) return;
const dpr = devicePixelRatio || 1;
const r = wrap.getBoundingClientRect();
const cw = r.width + PAD * 2;
const ch = r.height + PAD * 2;
cvs.style.width = cw + "px";
cvs.style.height = ch + "px";
cvs.style.left = -PAD + "px";
cvs.style.top = -PAD + "px";
cvs.width = cw * dpr;
cvs.height = ch * dpr;
const ctx = cvs.getContext("2d")!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const wr = wrap.getBoundingClientRect();
const spans = charsRef.current;
const N = spans.length;
blobsRef.current = spans.map((el, i) => {
const rect = el.getBoundingClientRect();
const t = N > 1 ? i / (N - 1) : 0.5;
const h = rect.height;
return {
cx: rect.left + rect.width / 2 - wr.left + PAD,
cy: rect.top + h * 0.82 - wr.top + PAD,
wx: rect.left + rect.width / 2 - wr.left,
wy: rect.top + rect.height / 2 - wr.top,
h,
colorPhase: hash(i * 7) * Math.PI * 2,
color: colorAt(t),
capR: h * (2.5 + hash(i * 3) * 1.5),
riseH: h * (1.5 + hash(i * 3 + 1) * 0.8),
a: 0,
};
});
}, []);
useEffect(() => {
const c = cfgRef.current;
const p = PRESETS[preset];
if (p) Object.assign(c, p);
const cvs = canvasRef.current;
if (cvs) cvs.style.filter = c.canvasBlur > 0 ? `blur(${c.canvasBlur}px)` : "";
}, [preset]);
useEffect(() => {
init();
const wrap = wrapRef.current;
const cvs = canvasRef.current;
if (!wrap || !cvs) return;
const ctx = cvs.getContext("2d")!;
const c = cfgRef.current;
cvs.style.filter = `blur(${c.canvasBlur}px)`;
const onEnter = (e: MouseEvent) => {
const rect = wrap.getBoundingClientRect();
mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top, active: true };
if (!rafRef.current) rafRef.current = requestAnimationFrame(tick);
};
const onMove = (e: MouseEvent) => {
const rect = wrap.getBoundingClientRect();
mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top, active: true };
if (!rafRef.current) rafRef.current = requestAnimationFrame(tick);
};
const onLeave = () => { mouseRef.current.active = false; };
wrap.addEventListener("mouseenter", onEnter);
wrap.addEventListener("mousemove", onMove);
wrap.addEventListener("mouseleave", onLeave);
function tick() {
const blobs = blobsRef.current;
const spans = charsRef.current;
const mouse = mouseRef.current;
const now = performance.now();
let anyActive = false;
const dpr = devicePixelRatio || 1;
const w = cvs!.width / dpr;
const h = cvs!.height / dpr;
ctx.clearRect(0, 0, w, h);
blobs.forEach((b, idx) => {
const globalT = (now * c.colorSpeed) % 1;
const letterOffset = Math.sin(now * 0.0003 + b.colorPhase) * c.colorSpread;
b.color = colorAt((globalT + letterOffset + 1) % 1);
let target = 0;
if (mouse.active) {
const dx = mouse.x - b.wx;
const dy = mouse.y - b.wy;
const dist = Math.sqrt(dx * dx + dy * dy);
const inner = b.h * c.innerR;
const outer = b.h * c.outerR;
target = 1 - smoothstep(inner, outer, dist);
}
const speed = target > b.a ? c.speedIn : c.speedOut;
b.a += (target - b.a) * speed;
if (b.a < 0.002) b.a = 0;
else anyActive = true;
const a = b.a;
const el = spans[idx];
if (a > 0.003) {
anyActive = true;
const lift = easeOut(a) * -c.lift;
const blur = Math.pow(a, c.blurPow) * c.maxBlur;
const scale = 1 + Math.pow(a, c.scalePow) * (c.maxScale - 1);
const cd = c.colorDelay;
const ct = Math.max(0, (a - cd) / (1 - cd));
const ct2 = ct * ct;
const [cr, cg, cb] = b.color;
const rr = Math.round(26 + (cr - 26) * ct2);
const gg = Math.round(26 + (cg - 26) * ct2);
const bb = Math.round(46 + (cb - 46) * ct2);
const ot = c.opacityThresh;
const opacity = a > ot ? Math.max(0, 1 - (a - ot) / (1 - ot)) : 1;
el.style.filter = blur > 0.3 ? `blur(${blur.toFixed(1)}px)` : "";
el.style.transform = `scale(${scale.toFixed(3)}) translateY(${lift.toFixed(1)}px)`;
el.style.opacity = opacity.toFixed(3);
el.style.color = `rgb(${rr},${gg},${bb})`;
} else {
el.style.filter = "";
el.style.transform = "";
el.style.opacity = "";
el.style.color = "";
}
if (a > 0.005) {
const bd = c.blobDelay;
const blobA = Math.max(0, (a - bd) / (1 - bd));
if (blobA > 0.005) {
const r = b.capR * c.blobRadius * blobA * blobA;
const cx = b.cx;
const letterLift = easeOut(a) * c.lift;
const cy = b.cy - letterLift * c.blobLiftFollow - b.riseH * blobA * blobA * c.blobRiseMult;
const [cr, cg, cb] = b.color;
const alpha = blobA * blobA;
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
const bo = c.blobOpacity;
grad.addColorStop(0, `rgba(${cr},${cg},${cb},${(bo * alpha).toFixed(3)})`);
grad.addColorStop(0.25, `rgba(${cr},${cg},${cb},${(bo * 0.68 * alpha).toFixed(3)})`);
grad.addColorStop(0.5, `rgba(${cr},${cg},${cb},${(bo * 0.32 * alpha).toFixed(3)})`);
grad.addColorStop(0.75, `rgba(${cr},${cg},${cb},${(bo * 0.08 * alpha).toFixed(3)})`);
grad.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
}
}
});
if (anyActive || mouse.active) {
rafRef.current = requestAnimationFrame(tick);
} else {
ctx.clearRect(0, 0, w, h);
rafRef.current = null;
}
}
const onResize = () => { init(); };
window.addEventListener("resize", onResize);
return () => {
wrap.removeEventListener("mouseenter", onEnter);
wrap.removeEventListener("mousemove", onMove);
wrap.removeEventListener("mouseleave", onLeave);
window.removeEventListener("resize", onResize);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [init]);
const chars = PHRASE.split("");
return (
<div className="fixed inset-0 bg-[#f5f3f0] flex flex-col items-center justify-center select-none">
<div
ref={wrapRef}
className="relative cursor-default"
style={{ padding: "0 2rem" }}
>
<canvas
ref={canvasRef}
className="absolute pointer-events-none"
style={{ zIndex: 1 }}
/>
<div
className="relative text-center whitespace-nowrap"
style={{
zIndex: 3,
fontFamily: "var(--font-flux), Georgia, serif",
lineHeight: 1.18,
letterSpacing: "-0.01em",
}}
>
{chars.map((ch, i) => (
<span
key={i}
ref={(el) => { if (el) charsRef.current[i] = el; }}
className="inline-block"
style={{
fontSize: `${FONT_SIZE_VW}vw`,
fontVariationSettings: "'wght' 300, 'SRIF' 500",
color: "#1a1a2e",
lineHeight: 1.15,
}}
>
{ch}
</span>
))}
</div>
</div>
<div className="flex gap-3 mt-12" style={{ zIndex: 10 }}>
{Object.keys(PRESETS).map((name) => (
<button
key={name}
onClick={() => setPreset(name)}
className="text-[10px] tracking-[0.08em] uppercase px-3 py-1.5 rounded-full border transition-colors"
style={{
fontVariationSettings: "'wght' 400, 'SRIF' 100",
borderColor: preset === name ? "#1a1a2e" : "rgba(26,26,46,0.15)",
background: preset === name ? "#1a1a2e" : "transparent",
color: preset === name ? "#f5f3f0" : "rgba(26,26,46,0.5)",
}}
>
{name}
</button>
))}
</div>
<div className="fixed bottom-6 left-6" style={{ zIndex: 10 }}>
<ScrambleLink
from="PROXIMITY"
to="← LAB"
href="/lab"
className="text-[11px] tracking-[0.1em] text-foreground/30 uppercase hover:text-foreground/60 transition-colors"
style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }}
/>
</div>
</div>
);
}