Baktiyar Ablaikhan

Sunset Logo Slam

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

The word sunset squashes, slams forward into a chunky retro logo trailing a navy-to-gold fan of dawn colour, cools to a sticker, holds, then unwinds back to the start; hover warms the light.

Code

"use client";

import { approach, clamp, hex, mixRGB, rgbStr, smooth, useStage, type RGB, type Scene } from "./stage";

/*
 * "sunset" — a chunky pixel-slab word squashes, slams forward into an extruded
 * retro logo whose fan converges on a vanishing point below the baseline,
 * cools to a sticker, holds, and unwinds back to frame zero.
 * Geometry tracks are in reference pixels of a 1500x1000 frame at 25fps.
 */

const REF_W = 1500;
const REF_H = 1000;
const FPS = 25;
const VP_DEPTH = 2.17;
const OUTLINE = 0.084;

const RAMP: [number, RGB][] = [
  [0.0, [18, 24, 66]],
  [0.22, [24, 30, 84]],
  [0.42, [38, 40, 108]],
  [0.58, [58, 49, 128]],
  [0.7, [86, 58, 142]],
  [0.79, [124, 68, 148]],
  [0.86, [168, 84, 142]],
  [0.885, [186, 100, 132]],
  [0.91, [206, 122, 120]],
  [0.945, [234, 152, 106]],
  [0.968, [248, 184, 104]],
  [0.985, [253, 214, 128]],
  [1.0, [255, 240, 190]],
];

// word box width, box centre y, fan depth — per reference frame
const WW = [
  506, 552, 600, 617, 625, 631, 635, 636, 634, 631, 625, 617, 593, 412, 302, 309, 335, 403, 612, 726,
  792, 835, 864, 885, 901, 913, 922, 930, 937, 942, 946, 950, 952, 952, 949, 937, 919, 904, 870, 476,
  519, 545, 582, 611, 621, 628, 632, 635, 635, 635, 637,
];
const WCY = [
  500, 495, 489.5, 487.5, 486.5, 486, 485.5, 485.5, 485.5, 486, 486.5, 487.5, 492, 515, 532, 529.5, 527,
  507.5, 425, 393.5, 378, 367.5, 360, 353, 345, 338, 332, 328, 324, 321, 318, 316.5, 315.5, 315.5, 316,
  320, 325.5, 329, 341, 545, 540, 513, 499, 491, 487, 485.5, 485.5, 486, 486, 486, 485.5,
];
const DEPTH = [
  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17, 29, 63, 160, 215, 246, 267, 281, 290, 298, 304, 309,
  312, 315, 318, 320, 321, 322, 322, 321, 314, 305, 291, 259, 47, 44, 50, 56, 60, 63, 65, 66, 66, 66, 66,
  67,
];
// extra vertical squash on top of the uniform scale (the anticipation)
const SQUASH: [number, number][] = [
  [11, 1],
  [13, 0.78],
  [14, 0.68],
  [15, 0.7],
  [16, 0.8],
  [17, 0.93],
  [18, 1.04],
  [20, 1],
];
// colour window: [frame, face ramp pos, tip ramp pos, tip heat, glow]
const WINDOW: [number, number, number, number, number][] = [
  [0, 0.3, 0.3, 0, 0],
  [14, 0.3, 0.3, 0, 0],
  [15, 1.0, 0.86, 0.6, 1],
  [16, 0.96, 0.62, 0.4, 0.8],
  [17, 0.9, 0.45, 0.3, 0.6],
  [18, 1.0, 0.12, 0.2, 1],
  [20, 0.86, 0.2, 0.5, 0.9],
  [22, 0.55, 0.6, 0.9, 0.75],
  [24, 0.22, 0.9, 1, 0.6],
  [27, 0.1, 0.97, 1, 0.5],
  [34, 0.08, 0.98, 1, 0.45],
  [38, 0.1, 0.95, 1, 0.42],
  [39, 0.06, 0.62, 0.7, 0.32],
  [46, 0.04, 0.6, 0.6, 0.26],
  [50, 0.04, 0.6, 0.6, 0.26],
];

const LAST = 50; // frames after this hold the last value
const HOLD_END = 88;
const BRIDGE = 5;
const OUTRO = 36;
// loop length in clock frames
const LOOP = HOLD_END + BRIDGE - 1 + OUTRO;

// Pixel-slab glyphs on one grid: 40u stems, 24u bars, x-height 22..122.
const G: Record<string, { w: number; r: number[][] }> = {
  s: { w: 104, r: [[0, 22, 104, 24], [0, 22, 40, 62], [0, 60, 104, 24], [64, 60, 40, 62], [0, 98, 104, 24]] },
  u: { w: 104, r: [[0, 22, 40, 100], [64, 22, 40, 100], [0, 98, 104, 24]] },
  n: { w: 104, r: [[0, 22, 40, 100], [64, 22, 40, 100], [0, 22, 104, 24]] },
  e: { w: 104, r: [[0, 22, 40, 100], [0, 22, 104, 24], [64, 22, 40, 62], [0, 60, 104, 24], [0, 98, 104, 24]] },
  t: { w: 92, r: [[24, 0, 40, 122], [0, 22, 92, 24], [24, 98, 68, 24]] },
};
const BOX_H = 122;

function buildWord(word: string) {
  const path = new Path2D();
  let x = 0;
  for (const ch of word) {
    const g = G[ch];
    for (const [rx, ry, rw, rh] of g.r) path.rect(x + rx, ry, rw, rh);
    x += g.w + 18;
  }
  return { path, w: x - 18 };
}

function buildLut() {
  const lut: RGB[] = [];
  for (let i = 0; i < 256; i++) {
    const u = i / 255;
    let j = 0;
    while (j < RAMP.length - 2 && RAMP[j + 1][0] < u) j++;
    const [a, ca] = RAMP[j];
    const [b, cb] = RAMP[j + 1];
    lut.push(mixRGB(ca, cb, smooth((u - a) / (b - a))));
  }
  // two box-blur passes to hide the stop seams
  for (let pass = 0; pass < 2; pass++) {
    const src = lut.map((c) => [...c] as RGB);
    for (let i = 0; i < 256; i++) {
      const acc: RGB = [0, 0, 0];
      let n = 0;
      for (let k = -3; k <= 3; k++) {
        const c = src[Math.min(255, Math.max(0, i + k))];
        acc[0] += c[0];
        acc[1] += c[1];
        acc[2] += c[2];
        n++;
      }
      lut[i] = [acc[0] / n, acc[1] / n, acc[2] / n];
    }
  }
  return { rgb: lut, css: lut.map((c) => rgbStr(c)) };
}

const tab = (arr: number[], f: number) => {
  const x = Math.min(LAST, Math.max(0, f));
  const i = Math.floor(x);
  const j = Math.min(arr.length - 1, i + 1);
  return arr[i] + (arr[j] - arr[i]) * (x - i);
};
const keyed = <T extends number[]>(rows: [number, ...T][], f: number, col: number) => {
  if (f <= rows[0][0]) return rows[0][col] as number;
  for (let i = 0; i < rows.length - 1; i++) {
    const a = rows[i];
    const b = rows[i + 1];
    if (f <= b[0]) {
      const k = smooth((f - a[0]) / (b[0] - a[0]));
      return (a[col] as number) + ((b[col] as number) - (a[col] as number)) * k;
    }
  }
  return rows[rows.length - 1][col] as number;
};

type Pose = { ww: number; cy: number; d: number; sy: number; face: number; tip: number; heat: number; glow: number };

function pose(clock: number): Pose {
  // clock frames -> measured frames, with the hard cut at 38->39 bridged
  let f: number;
  let outro = -1;
  if (clock < 38) f = clock;
  else if (clock < 38 + BRIDGE) {
    const k = (clock - 38) / BRIDGE;
    f = 38 + (1 - (1 - k) * (1 - k));
  } else if (clock < HOLD_END + BRIDGE - 1) f = clock - BRIDGE + 1;
  else {
    f = LAST;
    outro = (clock - (HOLD_END + BRIDGE - 1)) / OUTRO;
  }
  const p: Pose = {
    ww: tab(WW, f),
    cy: tab(WCY, f),
    d: tab(DEPTH, f),
    sy: keyed(SQUASH, f, 1),
    face: keyed(WINDOW, f, 1),
    tip: keyed(WINDOW, f, 2),
    heat: keyed(WINDOW, f, 3),
    glow: keyed(WINDOW, f, 4),
  };
  if (outro >= 0) {
    // light dies first, then the geometry unwinds to frame zero
    const light = 1 - smooth(outro / 0.35);
    p.glow *= light;
    p.heat *= light;
    p.tip = p.face + (p.tip - p.face) * (0.4 + 0.6 * light);
    const g = smooth((outro - 0.25) / 0.75);
    p.d *= 1 - g;
    p.ww = p.ww + (WW[0] - p.ww) * g;
    p.cy = p.cy + (WCY[0] - p.cy) * g;
  }
  return p;
}

function createScene(): Scene {
  const word = buildWord("sunset");
  const lut = buildLut();
  let hover = 0;
  const at = (u: number) => Math.round(clamp(u) * 255);

  return {
    frame({ ctx, w, h, dpr, t, dt, p }) {
      hover = approach(hover, p.inside ? 1 : 0, p.inside ? 5 : 1.6, dt);
      const clock = ((t * FPS) % LOOP + LOOP) % LOOP;
      const ps = pose(clock);

      // sky
      const sky = ctx.createLinearGradient(0, 0, 0, h);
      sky.addColorStop(0, "#080b16");
      sky.addColorStop(0.78, "#171a33");
      sky.addColorStop(1, "#1d1b3a");
      ctx.fillStyle = sky;
      ctx.fillRect(0, 0, w, h);

      const S = Math.min(w / REF_W, h / REF_H) * 1.02;
      const ox = (w - REF_W * S) / 2;
      const oy = (h - REF_H * S) / 2 + h * 0.06;
      const k = ps.ww / word.w;
      const sy = ps.sy;
      const cx = REF_W / 2;
      const cy = ps.cy;
      const boxH = BOX_H * k * sy;
      const baseY = cy + boxH / 2;
      const vpx = cx;
      const vpy = baseY + VP_DEPTH * boxH;
      const shift = 0.07 * hover;
      const face = clamp(ps.face + shift * (ps.face > 0.5 ? 0.3 : 1));
      const tip = clamp(ps.tip + shift);

      // layered glow, each wider layer a step cooler
      if (ps.glow > 0.002) {
        const gx = ox + cx * S;
        const gy = oy + (cy + Math.min(ps.d, 200) * 0.35) * S;
        const base = ps.ww * S;
        const layers: [number, number, number][] = [
          [0.35, 0.97, 0.32],
          [0.7, 0.88, 0.22],
          [1.1, 0.74, 0.14],
          [1.6, 0.52, 0.09],
        ];
        ctx.globalCompositeOperation = "lighter";
        for (const [r, u, a] of layers) {
          const c = lut.rgb[at(u + shift * 0.6)];
          const g = ctx.createRadialGradient(gx, gy, 0, gx, gy, base * r);
          g.addColorStop(0, rgbStr(c, a * ps.glow));
          g.addColorStop(1, rgbStr(c, 0));
          ctx.fillStyle = g;
          ctx.fillRect(0, 0, w, h);
        }
        ctx.globalCompositeOperation = "source-over";
      }

      const place = (s: number) => {
        const a = S * s * k;
        const d = S * s * k * sy;
        const e = ox + S * (vpx + s * (cx - vpx) - s * k * (word.w / 2));
        const f = oy + S * (vpy + s * (cy - vpy) - s * k * sy * (BOX_H / 2));
        ctx.setTransform(a * dpr, 0, 0, d * dpr, e * dpr, f * dpr);
      };

      // the fan: one copy per step of depth, deepest first; copy zero is the outline
      if (ps.d > 0.5) {
        const vpDist = VP_DEPTH * boxH;
        const n = Math.max(2, Math.min(150, Math.ceil(ps.d)));
        ctx.lineJoin = "miter";
        ctx.lineWidth = 2 * OUTLINE * BOX_H;
        for (let i = n; i >= 0; i--) {
          const u = i / n;
          const d = ps.d * u;
          const s = Math.max(0.02, 1 - d / vpDist);
          let c = lut.rgb[at(face + (tip - face) * u)];
          if (u > 0.92) c = mixRGB(c, lut.rgb[255], ((u - 0.92) / 0.08) * ps.heat);
          const css = rgbStr(c);
          place(s);
          ctx.fillStyle = css;
          ctx.strokeStyle = css;
          ctx.fill(word.path);
          ctx.stroke(word.path);
        }
      }

      // the face: lit from just below the top edge, picking up the fan's colour low down
      place(1);
      const low = mixRGB(hex("#c9cbe4"), lut.rgb[at(face)], 0.35 * Math.min(1, ps.heat + ps.glow));
      const fg = ctx.createLinearGradient(0, 0, 0, BOX_H);
      fg.addColorStop(0, "#ffffff");
      fg.addColorStop(0.18 + 0.1, "#ffffff");
      fg.addColorStop(0.6, "#f4f4fb");
      fg.addColorStop(1, rgbStr(low));
      ctx.fillStyle = fg;
      ctx.fill(word.path);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    },
  };
}

export default function SunsetSlam() {
  const ref = useStage(createScene, 2.6);
  return <div ref={ref} className="relative h-full w-full overflow-hidden bg-[#0b0f1f]" />;
}
Prompt

Build "Sunset Logo Slam", a text animation. What it does: The word sunset squashes, slams forward into a chunky retro logo trailing a navy-to-gold fan of dawn colour, cools to a sticker, holds, then unwinds back to the start; hover warms the light. 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).