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