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).