Baktiyar Ablaikhan

Cloud Mask Corners

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

Two small text blocks in opposite corners materialise through a soft cloudy mask, then clear and return with new words.

Code

"use client";

import { createNoise3D } from "simplex-noise";
import { clamp, makeCanvas, mulberry32, paintCloudMask, smooth, useStage, type Noise3, type Scene } from "./stage";

/*
 * Two small text blocks in opposite corners materialise through a soft cloudy
 * mask while un-blurring into focus, hold, clear the same way, and come back
 * with new words.
 */

const SETS: [string[], string[]][] = [
  [["Form follows", "feeling."], ["Slow is", "smooth."]],
  [["Quiet type,", "loud ideas."], ["Made in", "the margins."]],
  [["Fog lifts,", "words land."], ["Nothing", "is final."]],
  [["Read between", "the clouds."], ["Soft edges,", "sharp intent."]],
];
const IN = 1.3;
const HOLD = 1.9;
const OUT = 1.0;
const GAP = 0.45;
const CYCLE = IN + HOLD + OUT + GAP;
const GW = 40;
const GH = 22;

type Block = {
  text: { c: HTMLCanvasElement; ctx: CanvasRenderingContext2D };
  tmp: { c: HTMLCanvasElement; ctx: CanvasRenderingContext2D };
  mask: { c: HTMLCanvasElement; ctx: CanvasRenderingContext2D };
  img: ImageData;
  key: string;
};

function createScene(): Scene {
  const noise: Noise3 = createNoise3D(mulberry32(21));
  let blocks: Block[] = [];
  let bw = 0;
  let bh = 0;
  let r = 1;

  const setup = (w: number, h: number, dpr: number) => {
    r = Math.min(2, dpr);
    bw = Math.round(w * 0.42);
    bh = Math.round(h * 0.3);
    blocks = [0, 1].map(() => {
      const mask = makeCanvas(GW, GH);
      return {
        text: makeCanvas(bw * r, bh * r),
        tmp: makeCanvas(bw * r, bh * r),
        mask,
        img: mask.ctx.createImageData(GW, GH),
        key: "",
      };
    });
  };

  const renderText = (b: Block, lines: string[], right: boolean, h: number) => {
    const c = b.text.ctx;
    c.setTransform(r, 0, 0, r, 0, 0);
    c.clearRect(0, 0, bw, bh);
    const size = Math.max(11, h * 0.075);
    c.font = `600 ${size}px "Helvetica Neue", Inter, "Segoe UI", Arial, sans-serif`;
    c.fillStyle = right ? "#2b46b8" : "#151515";
    c.textAlign = right ? "right" : "left";
    c.textBaseline = "top";
    lines.forEach((line, i) => c.fillText(line, right ? bw - 2 : 2, (right ? bh - lines.length * size * 1.15 : 0) + i * size * 1.15));
  };

  return {
    resize(w, h, dpr) {
      setup(w, h, dpr);
    },
    frame({ ctx, w, h, t }) {
      ctx.fillStyle = "#fbfaf7";
      ctx.fillRect(0, 0, w, h);
      if (!blocks.length) return;
      const pad = Math.min(w, h) * 0.08;
      blocks.forEach((b, bi) => {
        const tt = t - bi * 0.35;
        const n = Math.floor(tt / CYCLE);
        const local = tt - n * CYCLE;
        const set = SETS[((n % SETS.length) + SETS.length) % SETS.length];
        const lines = set[bi];
        const key = `${n}:${bi}`;
        if (b.key !== key) {
          renderText(b, lines, bi === 1, h);
          b.key = key;
        }
        let prog: number;
        if (local < IN) prog = smooth(local / IN);
        else if (local < IN + HOLD) prog = 1;
        else if (local < IN + HOLD + OUT) prog = 1 - smooth((local - IN - HOLD) / OUT);
        else prog = 0;
        if (prog <= 0.001) return;
        paintCloudMask(b.mask.ctx, b.img, noise, prog, tt + bi * 10, 0.16, bi ? [0.4, 0.6] : [0.6, 0.4]);
        const T = b.tmp.ctx;
        const W = b.tmp.c.width;
        const H = b.tmp.c.height;
        T.globalCompositeOperation = "source-over";
        T.clearRect(0, 0, W, H);
        const blur = (1 - prog) * 7 * r;
        if (blur > 0.3) {
          T.save();
          T.shadowColor = bi ? "#2b46b8" : "#151515";
          T.shadowBlur = blur;
          T.shadowOffsetX = W * 2;
          T.drawImage(b.text.c, -W * 2, 0);
          T.restore();
          T.globalAlpha = clamp((prog - 0.5) * 2);
          T.drawImage(b.text.c, 0, 0);
          T.globalAlpha = 1;
        } else T.drawImage(b.text.c, 0, 0);
        T.globalCompositeOperation = "destination-in";
        T.imageSmoothingEnabled = true;
        T.drawImage(b.mask.c, 0, 0, W, H);
        const x = bi ? w - pad - bw : pad;
        const y = bi ? h - pad - bh : pad;
        ctx.drawImage(b.tmp.c, x, y, bw, bh);
      });
    },
  };
}

export default function CloudCorners() {
  const ref = useStage(createScene, 2);
  return <div ref={ref} className="relative h-full w-full overflow-hidden bg-[#fbfaf7]" />;
}
Prompt

Build "Cloud Mask Corners", a text animation. What it does: Two small text blocks in opposite corners materialise through a soft cloudy mask, then clear and return with new words. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using simplex-noise. 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).