Baktiyar Ablaikhan

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