Slamming Word Carousel
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
Type, Grid, Color and Motion sit small–large–small on one line and slam one slot left every half second with a horizontal motion blur, cycling which word is large.
Code
"use client";
import { clamp, hex, mixRGB, rgbStr, useStage, type Scene } from "./stage";
/*
* Type / Grid / Color / Motion — small word, BIG word, small word on one line,
* slamming one slot left every half second with a velocity-scaled smear.
* The next-next word waits as a tiny ghost behind the big one.
*/
const WORDS = ["Type", "Grid", "Color", "Motion"];
const DEEP = hex("#002fa7");
const PALE = hex("#7fb2ff");
const STEP = 0.5;
const LEAD = 0.06; // wind-up starts before the visible cut
const SHUTTER = 0.033;
// slots: left, centre, right, hidden-behind-centre
const SLOT_X = [0.235, 0.49, 0.745, 0.49];
const SLOT_S = [0.35, 1, 0.35, 0.145];
// fat-tailed sigmoid, monotone cubic through measured-feel control points
const EX = [0, 0.06, 0.14, 0.22, 0.32, 0.45, 0.62, 0.8, 1];
const EY = [0, 0.015, 0.16, 0.62, 0.86, 0.94, 0.978, 0.994, 1];
const slopes = (() => {
const n = EX.length;
const d: number[] = [];
for (let i = 0; i < n - 1; i++) d.push((EY[i + 1] - EY[i]) / (EX[i + 1] - EX[i]));
const m: number[] = [d[0]];
for (let i = 1; i < n - 1; i++) m.push(d[i - 1] * d[i] <= 0 ? 0 : (2 * d[i - 1] * d[i]) / (d[i - 1] + d[i]));
m.push(d[n - 2]);
return m;
})();
function ease(x: number) {
if (x <= 0) return 0;
if (x >= 1) return 1;
let i = 0;
while (x > EX[i + 1]) i++;
const hh = EX[i + 1] - EX[i];
const tt = (x - EX[i]) / hh;
const t2 = tt * tt;
const t3 = t2 * tt;
return (
(2 * t3 - 3 * t2 + 1) * EY[i] +
(t3 - 2 * t2 + tt) * hh * slopes[i] +
(-2 * t3 + 3 * t2) * EY[i + 1] +
(t3 - t2) * hh * slopes[i + 1]
);
}
type Pose = { x: number; s: number; a: number; top: number };
/** Pose of word `wi` at time t, x and s as fractions. */
function pose(wi: number, t: number): Pose {
const tt = t + LEAD;
const n = Math.floor(tt / STEP);
const local = (tt - n * STEP) / STEP;
const e = ease(local);
const slotAt = (step: number) => {
const q = (((wi - step) % 4) + 4) % 4; // 0 centre, 1 right, 2 hidden, 3 left
return [1, 2, 3, 0][q];
};
const from = slotAt(n - 1);
const to = slotAt(n);
if (from === 0 && to === 3) return { x: SLOT_X[3], s: SLOT_S[3], a: 0.4, top: 0 }; // pops in one frame
let x = SLOT_X[from] + (SLOT_X[to] - SLOT_X[from]) * e;
const s = SLOT_S[from] + (SLOT_S[to] - SLOT_S[from]) * e;
if (to === 1 && local > 0.62 * 0.5 && local < 0.5) {
// the arriving word lands: small overshoot that unwinds to exactly zero
const k = (local - 0.31) / 0.19;
x -= 0.004 * Math.sin(k * Math.PI);
}
const a = from === 3 ? 0.4 + 0.6 * e : 1;
return { x, s, a, top: to === 1 ? (e > 0.5 ? 3 : 1) : from === 1 ? (e > 0.5 ? 1 : 2) : to === 3 ? 0 : 1 };
}
function createScene(): Scene {
return {
frame({ ctx, w, h, t }) {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, w, h);
// cap height 77/450 of the height at full scale, but keep "Motion" inside the card
const capH = Math.min(h * 0.171, w * 0.115);
const font = capH / 0.72;
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
const items = WORDS.map((word, i) => ({ word, now: pose(i, t), prev: pose(i, t - SHUTTER) }));
items.sort((A, B) => A.now.top - B.now.top);
for (const { word, now, prev } of items) {
const m = clamp((now.s - 0.145) / (1 - 0.145));
const col = rgbStr(mixRGB(PALE, DEEP, 1 - (1 - m) * (1 - m)));
const dx = (now.x - prev.x) * w;
const size = font * now.s;
const travel = Math.abs(dx);
ctx.fillStyle = col;
const stamp = (x: number, s: number, a: number) => {
ctx.globalAlpha = a;
ctx.font = `700 ${font * s}px "Helvetica Neue", Helvetica, Inter, Arial, sans-serif`;
ctx.fillText(word, x, h / 2 + (capH * s) / 2);
};
if (travel > 3 && size > 22) {
// temporal supersampling across the shutter, trailing-weighted when fast
const n = Math.round(clamp(travel / 2.5, 4, 28));
const speed = clamp(travel / (w * 0.08));
for (let i = 0; i < n; i++) {
const f = i / (n - 1);
const x = prev.x * w + dx * f;
const s = prev.s + (now.s - prev.s) * f;
const headW = f * f;
const tailW = 1 - f * 0.6;
const wgt = headW * (1 - speed) + tailW * speed;
stamp(x, s, now.a * clamp((wgt * 2.4) / n + 0.02));
}
stamp(now.x * w, now.s, now.a * (1 - speed * 0.55));
} else {
stamp(now.x * w, now.s, now.a);
}
}
ctx.globalAlpha = 1;
},
};
}
export default function WordCarousel() {
const ref = useStage(createScene, 0.3);
return <div ref={ref} className="relative h-full w-full overflow-hidden bg-white" />;
}
Prompt
Build "Slamming Word Carousel", a text animation. What it does: Type, Grid, Color and Motion sit small–large–small on one line and slam one slot left every half second with a horizontal motion blur, cycling which word is large. 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).