NEMAWASHI LAB

Proximity

Canvas

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.

GIRAGIRA

Source

ProximityText.tsx382 lines
"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>
  );
}

NEMAWASHI — Kotaro Abe