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