Baktiyar Ablaikhan

Cloud letters

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

White letters drift in as soft clouds on a pale sky, come into grainy focus, then lift away; the pointer breathes on the nearest letters.

Code

"use client";

import {
  CanvasStudy,
  easeIn,
  easeOut,
  hash,
  makeCanvas,
  prog,
  supportsFilter,
  type Frame,
  type Painter,
} from "./stage";

export const BG = "#a9d4f2";
const TEXT = "Drift into focus";
const CYCLE = 9.5;

type Letter = {
  ch: string;
  x: number; // centre x
  pts: [number, number][]; // interior samples relative to the centre
  edge: [number, number][]; // edge samples
  step: number;
  breath: number; // smoothed pointer influence
};

function puffSprite() {
  const { c, ctx } = makeCanvas(64, 64);
  const g = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
  g.addColorStop(0, "rgba(255,255,255,0.9)");
  g.addColorStop(0.45, "rgba(255,255,255,0.35)");
  g.addColorStop(1, "rgba(255,255,255,0)");
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, 64, 64);
  return c;
}

export function create(): Painter {
  let key = "";
  let letters: Letter[] = [];
  let fs = 0;
  let puff: HTMLCanvasElement | null = null;

  const build = (ctx: CanvasRenderingContext2D, w: number, h: number, font: string) => {
    ctx.font = `700 100px ${font}`;
    fs = Math.min(h * 0.24, (w * 0.82 * 100) / ctx.measureText(TEXT).width);
    const f = `700 ${fs}px ${font}`;
    ctx.font = f;
    const total = ctx.measureText(TEXT).width;
    let x = (w - total) / 2;
    letters = [];
    const step = Math.max(2, Math.round(fs / 26));
    for (const ch of TEXT) {
      const cw = ctx.measureText(ch).width;
      if (ch !== " ") {
        const pad = fs * 0.2;
        const W = Math.ceil(cw + pad * 2);
        const H = Math.ceil(fs * 1.4);
        const { ctx: o } = makeCanvas(W, H);
        o.font = f;
        o.textBaseline = "middle";
        o.fillStyle = "#fff";
        o.fillText(ch, pad, H / 2);
        const data = o.getImageData(0, 0, W, H).data;
        const on = (px: number, py: number) => px >= 0 && py >= 0 && px < W && py < H && data[(py * W + px) * 4 + 3] > 120;
        const pts: [number, number][] = [];
        const edge: [number, number][] = [];
        for (let py = 0; py < H; py += step) {
          for (let px = 0; px < W; px += step) {
            if (!on(px, py)) continue;
            const p: [number, number] = [px - W / 2, py - H / 2];
            pts.push(p);
            if (!on(px + step, py) || !on(px - step, py) || !on(px, py + step) || !on(px, py - step)) edge.push(p);
          }
        }
        letters.push({ ch, x: x + cw / 2, pts, edge, step, breath: 0 });
      }
      x += cw;
    }
  };

  return ({ ctx, w, h, t, dt, p, fonts }: Frame) => {
    const k = `${w}|${h}|${fonts.sans}|${typeof document !== "undefined" ? document.fonts?.status : ""}`;
    if (k !== key) {
      key = k;
      build(ctx, w, h, fonts.sans);
    }
    puff ??= puffSprite();
    ctx.fillStyle = BG;
    ctx.fillRect(0, 0, w, h);

    const u = t % CYCLE;
    const baseY = h * 0.5 - (Math.max(0, u - 1.5) / CYCLE) * h * 0.14;
    const filter = supportsFilter(ctx);
    ctx.font = `700 ${fs}px ${fonts.sans}`;
    ctx.textBaseline = "middle";
    const n = letters.length;

    letters.forEach((L, i) => {
      const arrive = easeOut(prog(u, 0.3 + i * 0.12, 1.5));
      const leave = easeIn(prog(u, 5.6 + (n - 1 - i) * 0.07 + i * 0.03, 2.0));
      if (arrive <= 0) return;
      // pointer: a breath of wind on the nearest letters
      const dx0 = p.x - L.x;
      const dy0 = p.y - baseY;
      const near = p.inside ? Math.exp(-(dx0 * dx0 + dy0 * dy0) / (fs * fs * 0.9)) : 0;
      L.breath += (near - L.breath) * Math.min(1, dt * 6);

      const soft = Math.min(1, (1 - arrive) ** 0.7 + leave + L.breath * 0.18);
      const ox = (1 - arrive) * -w * 0.32 + leave * w * 0.3;
      const oy = (1 - arrive) * h * 0.38 - leave * h * 0.42 - L.breath * fs * 0.07;
      const alpha = 1 - leave ** 1.4;
      const cx = L.x + ox;
      const cy = baseY + oy;
      if (alpha <= 0.01) return;

      // crisp body
      const body = alpha * (1 - soft) ** 1.6;
      if (body > 0.01) {
        ctx.globalAlpha = body;
        if (filter && soft > 0.02) ctx.filter = `blur(${(soft * fs * 0.12).toFixed(1)}px)`;
        ctx.fillStyle = "#ffffff";
        ctx.fillText(L.ch, cx - ctx.measureText(L.ch).width / 2, cy);
        if (filter) ctx.filter = "none";
      }
      // cloud puffs
      if (soft > 0.03 && puff) {
        const r = L.step * (1.6 + soft * 5);
        const density = 1 - leave * 0.85;
        ctx.globalAlpha = Math.min(1, alpha * soft * 0.5);
        L.pts.forEach(([px, py], j) => {
          if (hash(j, i) > density) return;
          const drift = soft * fs * 0.35;
          const wob = t * 0.6 + j;
          const qx = cx + px * (1 + soft * 0.3) + (hash(j, 3) - 0.5) * drift + Math.sin(wob) * soft * 3;
          const qy = cy + py * (1 + soft * 0.3) + (hash(j, 9) - 0.5) * drift + Math.cos(wob * 0.8) * soft * 3;
          ctx.drawImage(puff as HTMLCanvasElement, qx - r, qy - r, r * 2, r * 2);
        });
      }
      // fine grain along the edges
      if (soft < 0.7) {
        ctx.globalAlpha = alpha * (1 - soft) * 0.75;
        ctx.fillStyle = "#ffffff";
        const g = Math.floor(t * 12);
        L.edge.forEach(([px, py], j) => {
          for (let s = 0; s < 2; s++) {
            const jx = (hash(j * 3 + s, g) - 0.5) * L.step * 2.4;
            const jy = (hash(j * 5 + s, g + 1) - 0.5) * L.step * 2.4;
            ctx.fillRect(cx + px + jx, cy + py + jy, 1.2, 1.2);
          }
        });
      }
    });
    ctx.globalAlpha = 1;
  };
}

export default function CloudLetters() {
  return (
    <CanvasStudy
      create={create}
      background={BG}
      still={3}
      label="White letters arrive as soft clouds on a pale sky, come into focus, drift up and lift away; the pointer breathes on the nearest letters."
    />
  );
}
Prompt

Build "Cloud letters", a text animation. What it does: White letters drift in as soft clouds on a pale sky, come into grainy focus, then lift away; the pointer breathes on the nearest letters. 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://www.arlan.me/vault (Arlan, MIT).