Baktiyar Ablaikhan

Flingable Word Stickers

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

Die-cut word stickers — wow, design, vault, yes!, arlan, ship it — that you can drag and fling so they slide, spin and bounce off the card edges.

Code

"use client";

import { makeCanvas, useStage, type Pointer, type Scene } from "./stage";

/*
 * Die-cut vinyl word stickers — wow, design, vault, yes!, arlan, ship it — each
 * in its own face. The die-cut is generated by dilating the word's silhouette.
 * Grab one and fling it: it carries momentum, spins a little and bounces off
 * the card edges. A tap gives it a wobble.
 */

const STICKERS = [
  { word: "wow", font: `700 1em "Times New Roman", Times, serif`, border: "#ff3d7f", fill: "#c2124f", x: 0.2, y: 0.24, rot: -0.06 },
  { word: "design", font: `700 1em Georgia, "Times New Roman", serif`, border: "#8b5cf6", fill: "#ffe14d", x: 0.68, y: 0.22, rot: 0.04 },
  { word: "ship it", font: `700 1em "Trebuchet MS", "Segoe UI", sans-serif`, border: "#2f6bff", fill: "#8ff3ff", x: 0.22, y: 0.52, rot: 0.03 },
  { word: "arlan", font: `400 1em "Courier New", Courier, monospace`, border: "#ffe14d", fill: "#e0242c", x: 0.53, y: 0.5, rot: -0.03 },
  { word: "yes!", font: `700 1em "Comic Sans MS", "Chalkboard SE", cursive`, border: "#ff8a1f", fill: "#3b1d0a", x: 0.33, y: 0.77, rot: 0.08 },
  { word: "vault", font: `900 1em Impact, "Arial Black", sans-serif`, border: "#14b8a6", fill: "#ff4f8b", x: 0.78, y: 0.74, rot: -0.05 },
];

type St = {
  i: number;
  sprite: HTMLCanvasElement | null;
  sw: number;
  sh: number;
  x: number;
  y: number;
  vx: number;
  vy: number;
  a: number;
  va: number;
  wob: number;
};

function renderSprite(i: number, fs: number, r: number) {
  const d = STICKERS[i];
  const font = d.font.replace("1em", `${fs * r}px`);
  const probe = makeCanvas(4, 4).ctx;
  probe.font = font;
  const tw = probe.measureText(d.word).width;
  const border = fs * 0.11 * r;
  const pad = border + fs * 0.25 * r;
  const W = tw + pad * 2;
  const H = fs * 1.35 * r + pad * 2;
  const base = makeCanvas(W, H);
  const c = base.ctx;
  c.font = font;
  c.textAlign = "center";
  c.textBaseline = "middle";
  // dilate: stamp the word around a ring
  c.fillStyle = d.border;
  const steps = 28;
  for (let k = 0; k < steps; k++) {
    const an = (k / steps) * Math.PI * 2;
    c.fillText(d.word, W / 2 + Math.cos(an) * border, H / 2 + Math.sin(an) * border);
    c.fillText(d.word, W / 2 + Math.cos(an) * border * 0.5, H / 2 + Math.sin(an) * border * 0.5);
  }
  // a thin white highlight line inside the cut, then the word
  c.fillStyle = "rgba(255,255,255,0.35)";
  for (let k = 0; k < 12; k++) {
    const an = (k / 12) * Math.PI * 2;
    c.fillText(d.word, W / 2 + Math.cos(an) * border * 0.25 - border * 0.15, H / 2 + Math.sin(an) * border * 0.25 - border * 0.15);
  }
  c.fillStyle = d.fill;
  c.fillText(d.word, W / 2, H / 2);
  // one soft shadow under the whole sticker
  const out = makeCanvas(W, H);
  out.ctx.shadowColor = "rgba(20,20,30,0.28)";
  out.ctx.shadowBlur = fs * 0.12 * r;
  out.ctx.shadowOffsetY = fs * 0.05 * r;
  out.ctx.drawImage(base.c, 0, 0);
  return { c: out.c, w: W / r, h: H / r };
}

function createScene(): Scene {
  let stickers: St[] = STICKERS.map((d, i) => ({ i, sprite: null, sw: 0, sh: 0, x: d.x, y: d.y, vx: 0, vy: 0, a: d.rot, va: 0, wob: 0 }));
  let W = 0;
  let H = 0;
  let drag: { s: St; ox: number; oy: number; sx: number; sy: number; moved: number } | null = null;

  const hit = (px: number, py: number) => {
    for (let k = stickers.length - 1; k >= 0; k--) {
      const s = stickers[k];
      const dx = px - s.x;
      const dy = py - s.y;
      const c = Math.cos(-s.a);
      const sn = Math.sin(-s.a);
      const lx = dx * c - dy * sn;
      const ly = dx * sn + dy * c;
      if (Math.abs(lx) < s.sw * 0.42 && Math.abs(ly) < s.sh * 0.36) return s;
    }
    return null;
  };

  return {
    resize(w, h, dpr) {
      const first = W === 0;
      const fs = Math.min(h * 0.13, w * 0.085);
      stickers = stickers.map((s) => {
        const sp = renderSprite(s.i, fs, Math.min(2, dpr));
        return {
          ...s,
          sprite: sp.c,
          sw: sp.w,
          sh: sp.h,
          x: first ? s.x * w : (s.x / W) * w,
          y: first ? s.y * h : (s.y / H) * h,
        };
      });
      W = w;
      H = h;
    },
    down(p: Pointer) {
      const s = hit(p.x, p.y);
      if (!s) return;
      stickers = stickers.filter((q) => q !== s).concat(s);
      drag = { s, ox: p.x - s.x, oy: p.y - s.y, sx: p.x, sy: p.y, moved: 0 };
      s.vx = 0;
      s.vy = 0;
    },
    up(p: Pointer) {
      if (!drag) return;
      const s = drag.s;
      if (drag.moved < 5) {
        s.wob = 1;
      } else {
        s.vx = Math.max(-2500, Math.min(2500, p.vx));
        s.vy = Math.max(-2500, Math.min(2500, p.vy));
        s.va = s.vx * 0.0025;
      }
      drag = null;
    },
    cursor: (p) => (drag ? "grabbing" : hit(p.x, p.y) ? "grab" : "default"),
    frame({ ctx, w, h, t, dt, p }) {
      ctx.fillStyle = "#fcfcfd";
      ctx.fillRect(0, 0, w, h);
      for (const s of stickers) {
        if (drag && drag.s === s) {
          s.x = p.x - drag.ox;
          s.y = p.y - drag.oy;
          drag.moved = Math.max(drag.moved, Math.hypot(p.x - drag.sx, p.y - drag.sy));
        } else {
          s.x += s.vx * dt;
          s.y += s.vy * dt;
          const fr = Math.exp(-dt * 2.4);
          s.vx *= fr;
          s.vy *= fr;
          s.a += s.va * dt;
          s.va *= Math.exp(-dt * 2.8);
          const hw = s.sw * 0.4;
          const hh = s.sh * 0.34;
          if (s.x < hw) {
            s.x = hw;
            s.vx = Math.abs(s.vx) * 0.62;
            s.va *= -0.6;
          }
          if (s.x > w - hw) {
            s.x = w - hw;
            s.vx = -Math.abs(s.vx) * 0.62;
            s.va *= -0.6;
          }
          if (s.y < hh) {
            s.y = hh;
            s.vy = Math.abs(s.vy) * 0.62;
          }
          if (s.y > h - hh) {
            s.y = h - hh;
            s.vy = -Math.abs(s.vy) * 0.62;
          }
        }
        s.wob = Math.max(0, s.wob - dt * 1.6);
        if (!s.sprite) continue;
        const sway = Math.sin(t * 1.1 + s.i * 1.7) * 0.012;
        const wobble = Math.sin(s.wob * 18) * 0.12 * s.wob;
        const lift = drag && drag.s === s ? 1.06 : 1;
        ctx.save();
        ctx.translate(s.x, s.y);
        ctx.rotate(s.a + sway + wobble);
        ctx.scale(lift, lift);
        ctx.drawImage(s.sprite, -s.sw / 2, -s.sh / 2, s.sw, s.sh);
        ctx.restore();
      }
    },
  };
}

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

Build "Flingable Word Stickers", a other animation. What it does: Die-cut word stickers — wow, design, vault, yes!, arlan, ship it — that you can drag and fling so they slide, spin and bounce off the card edges. 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).