Baktiyar Ablaikhan

Foggy word

Source
After bencho.dev ↗
Author
Interaction after bencho.dev
Licence
Original code — interaction after bencho.dev

Loading…

Wipe the fogged glass with your pointer to reveal the word beneath.

Code

"use client";

import { useEffect, useRef } from "react";
import { fitCanvas, prefersReducedMotion, Surface } from "../ui";

const WORD = "Clarity";

export default function FoggyWord() {
  const canvas = useRef<HTMLCanvasElement>(null);
  const probe = useRef<HTMLSpanElement>(null);
  const pointer = useRef({ x: 0, y: 0, px: 0, py: 0, on: false, last: 0 });

  useEffect(() => {
    const cv = canvas.current;
    if (!cv) return;
    const still = prefersReducedMotion();
    let ctx: CanvasRenderingContext2D | null = null;
    let W = 0;
    let H = 0;
    let raf = 0;
    let t = 0;
    const ghost = { x: 0, y: 0 };
    const fogLayer = document.createElement("canvas"); // blurred word + condensation
    const mask = document.createElement("canvas"); // where the fog still is
    const work = document.createElement("canvas");
    const sharp = document.createElement("canvas");

    const font = () => {
      const fam = probe.current ? getComputedStyle(probe.current).fontFamily : "Georgia, serif";
      const size = Math.min(W * 0.22, H * 0.42);
      return `${size}px ${fam}`;
    };

    const drawWord = (c: CanvasRenderingContext2D, color: string) => {
      c.font = font();
      c.textAlign = "center";
      c.textBaseline = "middle";
      c.fillStyle = color;
      c.fillText(WORD, W / 2, H / 2);
    };

    const build = () => {
      const r = cv.getBoundingClientRect();
      W = r.width;
      H = r.height;
      ctx = fitCanvas(cv, W, H);
      const s = fitCanvas(sharp, W, H);
      if (s) drawWord(s, "#151515");
      const f = fitCanvas(fogLayer, W, H);
      if (f) {
        f.fillStyle = "rgba(233,232,228,0.92)";
        f.fillRect(0, 0, W, H);
        f.filter = `blur(${Math.max(6, W * 0.018)}px)`;
        drawWord(f, "rgba(21,21,21,0.55)");
        f.filter = "none";
        // fine droplets
        for (let i = 0; i < W * H * 0.0016; i++) {
          const x = (Math.sin(i * 12.9898) * 43758.5453) % 1;
          const y = (Math.sin(i * 78.233) * 12345.678) % 1;
          f.fillStyle = `rgba(255,255,255,${0.35 + ((i * 7) % 5) * 0.08})`;
          f.beginPath();
          f.arc(Math.abs(x) * W, Math.abs(y) * H, 0.6 + ((i * 13) % 7) * 0.18, 0, Math.PI * 2);
          f.fill();
        }
      }
      const m = fitCanvas(mask, W, H);
      if (m) {
        m.fillStyle = "#000";
        m.fillRect(0, 0, W, H);
      }
      fitCanvas(work, W, H);
    };
    build();
    const ro = new ResizeObserver(build);
    ro.observe(cv);
    document.fonts?.ready.then(() => build());

    const wipe = (m: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number) => {
      const r = Math.min(W, H) * 0.11;
      const steps = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / (r * 0.3)));
      m.globalCompositeOperation = "destination-out";
      for (let i = 0; i <= steps; i++) {
        const x = x0 + ((x1 - x0) * i) / steps;
        const y = y0 + ((y1 - y0) * i) / steps;
        const g = m.createRadialGradient(x, y, 0, x, y, r);
        g.addColorStop(0, "rgba(0,0,0,0.5)");
        g.addColorStop(1, "rgba(0,0,0,0)");
        m.fillStyle = g;
        m.fillRect(x - r, y - r, r * 2, r * 2);
      }
      m.globalCompositeOperation = "source-over";
    };

    const loop = () => {
      t += 1 / 60;
      const m = mask.getContext("2d");
      const w = work.getContext("2d");
      const p = pointer.current;
      if (ctx && m && w) {
        m.setTransform(1, 0, 0, 1, 0, 0);
        const dpr = mask.width / Math.max(1, W);
        m.setTransform(dpr, 0, 0, dpr, 0, 0);
        // the fog slowly breathes back
        m.fillStyle = "rgba(0,0,0,0.012)";
        m.fillRect(0, 0, W, H);
        const gx = W / 2 + Math.cos(t * 0.9) * W * 0.3;
        const gy = H / 2 + Math.sin(t * 1.7) * H * 0.18;
        if (!p.on && performance.now() - p.last > 2500) {
          // a ghost finger while nobody is touching the glass
          wipe(m, ghost.x || gx, ghost.y || gy, gx, gy);
        } else if (p.on) {
          wipe(m, p.px, p.py, p.x, p.y);
          p.px = p.x;
          p.py = p.y;
        }
        ghost.x = gx;
        ghost.y = gy;
        w.setTransform(1, 0, 0, 1, 0, 0);
        w.clearRect(0, 0, work.width, work.height);
        w.globalCompositeOperation = "source-over";
        w.drawImage(fogLayer, 0, 0);
        w.globalCompositeOperation = "destination-in";
        w.drawImage(mask, 0, 0);
        w.globalCompositeOperation = "source-over";
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.clearRect(0, 0, cv.width, cv.height);
        ctx.drawImage(sharp, 0, 0);
        ctx.drawImage(work, 0, 0);
      }
      if (!still) raf = requestAnimationFrame(loop);
    };
    loop();
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
    };
  }, []);

  const at = (e: React.PointerEvent) => {
    const r = canvas.current!.getBoundingClientRect();
    return { x: e.clientX - r.left, y: e.clientY - r.top };
  };

  return (
    <Surface>
      <span ref={probe} className="pointer-events-none absolute font-serif opacity-0" aria-hidden>
        {WORD}
      </span>
      <canvas
        ref={canvas}
        aria-label={WORD}
        className="absolute inset-0 h-full w-full touch-none"
        onPointerEnter={(e) => {
          const p = at(e);
          Object.assign(pointer.current, { ...p, px: p.x, py: p.y, on: true, last: performance.now() });
        }}
        onPointerDown={(e) => {
          const p = at(e);
          Object.assign(pointer.current, { ...p, px: p.x, py: p.y, on: true, last: performance.now() });
        }}
        onPointerMove={(e) => Object.assign(pointer.current, at(e), { on: true, last: performance.now() })}
        onPointerLeave={() => Object.assign(pointer.current, { on: false, last: performance.now() })}
      />
      <p className="pointer-events-none absolute bottom-[6%] font-mono text-[0.7em] text-[#8a8984]">wipe the glass</p>
    </Surface>
  );
}
Prompt

Build "Foggy word", a text animation. What it does: Wipe the fogged glass with your pointer to reveal the word beneath. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS. Technique: an HTML canvas, a requestAnimationFrame loop, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).