Streak tear
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
'gone', 'before', 'you', 'look' hold sharp, then swing toward you and tear into green and violet streaks; pointing holds the word at its largest.
Code
"use client";
import { CanvasStudy, clamp, easeIn, easeOut, makeCanvas, type Frame, type Painter } from "./stage";
export const BG = "#07070a";
const WORDS = ["gone", "before", "you", "look"];
const DUR = 2.1;
const PEAK = 1.78; // the word at its largest
const TINTS = { white: "#f5f3ee", green: "#36f59a", violet: "#9b5cff" };
type Img = { c: HTMLCanvasElement; w: number; h: number };
export function create(): Painter {
let clock = 0;
let key = "";
const cache = new Map<string, Img>();
const word = (s: string, tint: keyof typeof TINTS, fs: number, font: string, dpr: number): Img => {
const k = `${s}|${tint}`;
const hit = cache.get(k);
if (hit) return hit;
const probe = makeCanvas(1, 1).ctx;
probe.font = `600 ${fs}px ${font}`;
const w = Math.ceil(probe.measureText(s).width + fs * 0.2);
const h = Math.ceil(fs * 1.3);
const { c, ctx } = makeCanvas(w * dpr, h * dpr);
ctx.scale(dpr, dpr);
ctx.font = `600 ${fs}px ${font}`;
ctx.textBaseline = "middle";
ctx.fillStyle = TINTS[tint];
ctx.fillText(s, fs * 0.1, h / 2);
const img = { c, w, h };
cache.set(k, img);
return img;
};
return ({ ctx, w, h, t, dt, p, fonts, dpr, reduced }: Frame) => {
if (reduced) clock = t;
else {
const local = clock % DUR;
const next = local + dt;
// pointing at it holds the word at its largest
if (p.inside && local <= PEAK && next >= PEAK) clock += PEAK - local;
else if (!(p.inside && Math.abs(local - PEAK) < 1e-6)) clock += dt;
}
ctx.fillStyle = BG;
ctx.fillRect(0, 0, w, h);
const fs = Math.min(h * 0.3, w * 0.16);
const k = `${w}|${h}|${dpr}|${fonts.sans}`;
if (k !== key) {
key = k;
cache.clear();
}
const n = Math.floor(clock / DUR);
const local = clock - n * DUR;
const s = WORDS[n % WORDS.length];
let scale = 1;
let stretch = 1;
let lift = 0;
let tear = 0;
let turn = 0;
if (local < 0.4) {
// falls back out of the blur
const e = easeOut(local / 0.4);
stretch = 1 + (1 - e) * 16;
scale = 1 + (1 - e) * 0.6;
tear = 1 - e;
lift = -(1 - e) * h * 0.1;
} else if (local > 1.15) {
const e = easeIn(clamp((local - 1.15) / (PEAK - 1.15)));
scale = 1 + e * 1.3;
stretch = 1 + e ** 1.6 * 24;
lift = -e * h * 0.22;
turn = -e * 0.05;
tear = clamp((e - 0.35) / 0.65);
if (local > PEAK) tear = 1; // last beat before the cut
}
const draw = (img: Img, dx: number, alpha: number) => {
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(w / 2 + dx, h / 2 + lift);
ctx.rotate(turn);
ctx.scale(scale, scale * stretch);
ctx.drawImage(img.c, -img.w / 2, -img.h / 2, img.w, img.h);
ctx.restore();
};
const white = word(s, "white", fs, fonts.sans, dpr);
if (tear > 0.01) {
ctx.globalCompositeOperation = "lighter";
const spread = fs * 0.12 * tear * scale;
draw(word(s, "green", fs, fonts.sans, dpr), -spread, 0.9 * tear);
draw(word(s, "violet", fs, fonts.sans, dpr), spread, 0.9 * tear);
ctx.globalCompositeOperation = "source-over";
}
draw(white, 0, 1 - tear * 0.75);
};
}
export default function StreakWords() {
return (
<CanvasStudy
create={create}
background={BG}
still={0.9}
label="'gone', 'before', 'you', 'look' hold sharp, then swing toward you and tear into green and violet streaks; pointing holds the word at its largest."
/>
);
}
Prompt
Build "Streak tear", a text animation. What it does: 'gone', 'before', 'you', 'look' hold sharp, then swing toward you and tear into green and violet streaks; pointing holds the word at its largest. 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 dark 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).