Baktiyar Ablaikhan

Glass lens

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

Black serif type seen through a colour-fringed glass lens across three scenes — unrolling line, word-swapping ball, magnifying wave; hovering carries the lens.

Code

"use client";

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

export const BG = "#ffffff";
const SCENE = 4.2;
const LINE = "Read between the lines";
const DRIFT = ["glass", "lens", "light", "bend", "warp"];

type Layer = ReturnType<typeof makeCanvas>;

export function create(): Painter {
  let ink: Layer | null = null; // scene type, transparent ground
  let waved: Layer | null = null;
  let tintR: Layer | null = null;
  let tintB: Layer | null = null;
  let lineImg: Layer | null = null;
  let lineKey = "";
  let lx = 0;
  let ly = 0;
  let follow = 0;

  const ensure = (l: Layer | null, W: number, H: number) => (l && l.c.width === W && l.c.height === H ? l : makeCanvas(W, H));

  return ({ ctx, w, h, t, dt, p, fonts, dpr }: Frame) => {
    const W = Math.round(w * dpr);
    const H = Math.round(h * dpr);
    ink = ensure(ink, W, H);
    waved = ensure(waved, W, H);
    tintR = ensure(tintR, W, H);
    tintB = ensure(tintB, W, H);
    const scene = Math.floor(t / SCENE) % 3;
    const u = t % SCENE;
    const c = ink.ctx;
    c.setTransform(dpr, 0, 0, dpr, 0, 0);
    c.clearRect(0, 0, w, h);
    c.fillStyle = "#111111";
    c.textBaseline = "middle";

    // where the lens wants to be
    let tx = w / 2 + Math.sin(t * 0.7) * w * 0.25;
    let ty = h / 2 + Math.sin(t * 1.1) * h * 0.12;
    let R = Math.min(w, h) * 0.22;
    let mag = 1.55;

    if (scene === 0) {
      // a line rises and unrolls to readable
      const fs = Math.min(h * 0.15, w * 0.085);
      const key = `${w}|${h}|${dpr}|${fonts.serif}`;
      if (key !== lineKey || !lineImg) {
        lineKey = key;
        c.font = `400 ${fs}px ${fonts.serif}`;
        const lw = Math.ceil(c.measureText(LINE).width + fs);
        const lh = Math.ceil(fs * 1.4);
        lineImg = makeCanvas(lw * dpr, lh * dpr);
        const o = lineImg.ctx;
        o.scale(dpr, dpr);
        o.font = `400 ${fs}px ${fonts.serif}`;
        o.textBaseline = "middle";
        o.fillStyle = "#111111";
        o.fillText(LINE, fs / 2, lh / 2);
      }
      const img = lineImg.c;
      const iw = img.width / dpr;
      const ih = img.height / dpr;
      const rise = easeOut(prog(u, 0.1, 1.2));
      const unroll = easeInOut(prog(u, 0.5, 1.6));
      const out = easeInOut(prog(u, SCENE - 0.6, 0.6));
      const y0 = h * 0.85 - rise * h * 0.35 - ih / 2 - out * h * 0.6;
      const N = 28;
      let y = y0;
      for (let j = 0; j < N; j++) {
        const v = j / N;
        if (v > unroll) break;
        const edge = clamp((unroll - v) / 0.22);
        const sh = (ih / N) * Math.sin((edge * Math.PI) / 2);
        c.drawImage(img, 0, (j * img.height) / N, img.width, img.height / N, (w - iw) / 2, y, iw, sh + 0.3);
        y += sh;
      }
    } else if (scene === 1) {
      // a glass ball swaps two words inside its boiling outline
      const fs = Math.min(h * 0.11, w * 0.07);
      c.font = `400 ${fs}px ${fonts.serif}`;
      const swap = easeInOut(prog(u, 1.1, 1.4));
      const lead = fs * 1.1;
      tx = w / 2;
      ty = h / 2;
      R = Math.min(w * 0.3, h * 0.42);
      mag = 1.18;
      const word = (s: string, x: number, yy: number) => c.fillText(s, x - c.measureText(s).width / 2, yy);
      word("follows", w / 2, h / 2);
      const a = swap * Math.PI;
      word("form", w / 2 + Math.sin(a) * R * 0.45, h / 2 - Math.cos(a) * lead);
      word("function", w / 2 - Math.sin(a) * R * 0.45, h / 2 + Math.cos(a) * lead);
    } else {
      // single words drift through a magnifying wave
      const fs = Math.min(h * 0.13, w * 0.08);
      c.font = `400 ${fs}px ${fonts.serif}`;
      DRIFT.forEach((s, i) => {
        const speed = 0.12 + hash(i, 2) * 0.1;
        const x = ((hash(i, 1) + t * speed) % 1.3) * (w + fs * 4) - fs * 3;
        const yy = h * (0.18 + (i / (DRIFT.length - 1)) * 0.64);
        c.fillText(s, x, yy);
      });
    }

    // the magnifying wave (scene 3) bends columns of the type
    const wv = waved.ctx;
    wv.setTransform(1, 0, 0, 1, 0, 0);
    wv.clearRect(0, 0, W, H);
    if (scene === 2) {
      const wx = ((u / SCENE) * 1.4 - 0.2) * W;
      const band = W * 0.12;
      const step = Math.max(2, Math.round(3 * dpr));
      for (let x = 0; x < W; x += step) {
        const s = 1 + 0.6 * Math.exp(-(((x - wx) / band) ** 2));
        const sh = H / s;
        wv.drawImage(ink.c, x, (H - sh) / 2, step, sh, x, 0, step, H);
      }
    } else {
      wv.drawImage(ink.c, 0, 0);
    }
    const src = waved.c;

    // tinted copies for the colour fringe
    for (const [layer, color] of [
      [tintR, "#ff2a3d"],
      [tintB, "#1f6bff"],
    ] as const) {
      const lc = layer.ctx;
      lc.globalCompositeOperation = "source-over";
      lc.clearRect(0, 0, W, H);
      lc.drawImage(src, 0, 0);
      lc.globalCompositeOperation = "source-in";
      lc.fillStyle = color;
      lc.fillRect(0, 0, W, H);
    }

    // lens follows the pointer when it is over the card
    follow += ((p.inside ? 1 : 0) - follow) * Math.min(1, dt * 6);
    if (p.inside) {
      tx = p.x;
      ty = p.y;
    }
    const e = Math.min(1, dt * (p.inside ? 12 : 3));
    if (lx === 0 && ly === 0) {
      lx = tx;
      ly = ty;
    }
    lx += (tx - lx) * e;
    ly += (ty - ly) * e;

    ctx.fillStyle = BG;
    ctx.fillRect(0, 0, w, h);
    ctx.drawImage(src, 0, 0, w, h);

    const zoom = (img: HTMLCanvasElement, m: number) => {
      ctx.save();
      ctx.translate(lx, ly);
      ctx.scale(m, m);
      ctx.translate(-lx, -ly);
      ctx.drawImage(img, 0, 0, w, h);
      ctx.restore();
    };
    ctx.save();
    ctx.beginPath();
    ctx.arc(lx, ly, R, 0, Math.PI * 2);
    ctx.clip();
    ctx.fillStyle = BG;
    ctx.fillRect(lx - R, ly - R, R * 2, R * 2);
    zoom(tintR.c, mag * 1.035);
    zoom(tintB.c, mag * 0.965);
    zoom(src, mag);
    // glass: a faint rim shade
    const g = ctx.createRadialGradient(lx, ly, R * 0.7, lx, ly, R);
    g.addColorStop(0, "rgba(120,140,170,0)");
    g.addColorStop(1, "rgba(120,140,170,0.16)");
    ctx.fillStyle = g;
    ctx.fillRect(lx - R, ly - R, R * 2, R * 2);
    ctx.restore();

    if (scene === 1) {
      // hand-drawn, boiling outline of the ball
      const boil = Math.floor(t * 9);
      ctx.strokeStyle = "#111111";
      ctx.lineWidth = Math.max(1.5, R * 0.018);
      ctx.lineJoin = "round";
      for (let pass = 0; pass < 2; pass++) {
        ctx.beginPath();
        for (let i = 0; i <= 48; i++) {
          const a = (i / 48) * Math.PI * 2;
          const r = R * (1 + (hash(i % 48, boil * 3 + pass) - 0.5) * 0.035);
          const x = lx + Math.cos(a) * r;
          const y = ly + Math.sin(a) * r;
          if (i === 0) ctx.moveTo(x, y);
          else ctx.lineTo(x, y);
        }
        ctx.stroke();
      }
      // a highlight stroke on the glass
      ctx.beginPath();
      ctx.arc(lx, ly, R * 0.8, Math.PI * 1.1, Math.PI * 1.35);
      ctx.stroke();
    } else {
      ctx.strokeStyle = `rgba(20,20,20,${0.18 + follow * 0.2})`;
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.arc(lx, ly, R, 0, Math.PI * 2);
      ctx.stroke();
    }
  };
}

export default function GlassLens() {
  return (
    <CanvasStudy
      create={create}
      background={BG}
      still={2.5}
      cursor="none"
      label="Black serif type warped by a glass lens with colour fringes — a line unrolls, a boiling glass ball swaps two words, words drift through a magnifying wave; the lens follows the pointer."
    />
  );
}
Prompt

Build "Glass lens", a cursor animation. What it does: Black serif type seen through a colour-fringed glass lens across three scenes — unrolling line, word-swapping ball, magnifying wave; hovering carries the lens. 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).