Cursor Swarm Sphere
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
Twenty-six named multiplayer cursors ride an invisible sphere around a breathing violet ring, growing at the front and blurring behind, occasionally typing or clicking; drag to spin it and your own cursor joins the swarm.
Code
"use client";
import { approach, clamp, makeCanvas, mulberry32, smooth, useStage, type Scene } from "./stage";
/*
* Twenty-six named multiplayer cursors riding an invisible, orthographically
* projected sphere around a thin breathing violet ring. Depth shows only as
* size, opacity and defocus. Drag to spin / throw; your own cursor joins in.
*/
const NAMES = [
"Ada", "Ben", "Cleo", "Dev", "Eli", "Fay", "Gus", "Hana", "Ivo", "Jun", "Kai", "Lia", "Max",
"Nia", "Oto", "Pia", "Quin", "Ray", "Sol", "Tess", "Uma", "Vic", "Wren", "Xia", "Yuki", "Zoe",
];
const HUES = ["#F24E1E", "#FF7262", "#A259FF", "#1ABCFE", "#0ACF83", "#FFC700", "#FF3D9A", "#5551FF"];
const DARK_TEXT = new Set(["#FFC700", "#0ACF83", "#1ABCFE"]);
const POINTS = Math.atan2(-0.8, -0.3);
const RING = "#6B3EB5";
const PHRASES = ["hi!", "nice", "ship it", "+1", "ooh", "lgtm", "wow", "brb"];
// Rate phrase over 27s: calm drift, build, whip, settle, held beat, recover.
const PHRASE: [number, number][] = [
[0, 0.45],
[13, 0.45],
[16, 1.2],
[16.95, 5.2],
[17.9, 1.4],
[20.8, 0.3],
[21.4, 0.05],
[24.6, 0.05],
[27, 0.45],
];
const phraseRate = (t: number) => {
const x = t % 27;
for (let i = 0; i < PHRASE.length - 1; i++) {
const [a, ra] = PHRASE[i];
const [b, rb] = PHRASE[i + 1];
if (x <= b) return ra + (rb - ra) * smooth((x - a) / (b - a));
}
return PHRASE[0][1];
};
type Mate = {
name: string;
color: string;
dark: boolean;
v: [number, number, number];
attn: number;
nudge: number;
nudgeDir: [number, number];
act: "none" | "type" | "click";
actT: number;
text: string;
};
type Drawn = { m: Mate; x: number; y: number; z: number; s: number; a: number; blur: number; look: number };
function cursorPath(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, rot: number) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(rot);
ctx.scale(size, size);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, 1);
ctx.lineTo(0.27, 0.76);
ctx.lineTo(0.66, 0.74);
ctx.closePath();
ctx.restore();
}
function drawMate(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
ph: number,
color: string,
dark: boolean,
label: string,
rot: number,
press: number,
) {
// arrow, tip exactly on the point
cursorPath(ctx, x, y, ph * 0.68 * (1 - press * 0.18), rot);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = Math.max(0.8, ph * 0.06);
ctx.strokeStyle = "#fff";
ctx.stroke();
// pill hangs below-right with a gap
ctx.font = `600 ${ph * 0.47}px ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif`;
const tw = ctx.measureText(label).width;
const pw = tw + ph * 0.42 * 2;
const px = x + ph * 0.55;
const py = y + ph * 0.55;
ctx.beginPath();
ctx.roundRect(px, py, pw, ph, ph * 0.32);
ctx.fillStyle = color;
ctx.fill();
ctx.fillStyle = dark ? "#1b1b1f" : "#fff";
ctx.textBaseline = "middle";
ctx.fillText(label, px + ph * 0.42, py + ph * 0.53);
}
function createScene(): Scene {
const rnd = mulberry32(26);
const mates: Mate[] = NAMES.map((name, i) => {
// fibonacci sphere, slightly jittered
const k = (i + 0.5) / NAMES.length;
const phi = Math.acos(1 - 2 * k);
const th = Math.PI * (1 + Math.sqrt(5)) * i + rnd() * 0.4;
const color = HUES[i % HUES.length];
return {
name,
color,
dark: DARK_TEXT.has(color),
v: [Math.sin(phi) * Math.cos(th), Math.cos(phi), Math.sin(phi) * Math.sin(th)],
attn: 0,
nudge: 0,
nudgeDir: [0, 0],
act: "none",
actT: 0,
text: "",
};
});
let angle = 0;
let rate = 0.45;
let rateVel = 0;
let throwRate = 0;
let dragging = false;
let lastX = 0;
let dragRate = 0;
let nextAct = 1.2;
const me = { x: 0, y: 0, a: 0, init: false };
let R = 100;
let sprite: { c: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null = null;
return {
down(p) {
dragging = true;
lastX = p.x;
dragRate = 0;
},
up() {
if (!dragging) return;
dragging = false;
throwRate = dragRate;
rate = dragRate;
},
cursor: (p) => (p.inside ? "none" : "default"),
frame({ ctx, w, h, dpr, t, dt, p }) {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, w, h);
const u = h / 540;
R = 195 * u;
const cx = w / 2;
const cy = h / 2;
// tips ride the sphere but pills hang down-right: shift so the mass centres on the ring
const sx = cx - 14 * u;
const sy = cy - 10 * u;
// ---- spin: integrated from a phrased rate on an under-damped spring
if (dragging) {
const dx = p.x - lastX;
lastX = p.x;
const inst = dt > 0 ? dx / R / dt : 0;
dragRate = dragRate * 0.6 + inst * 0.4;
rate = dragRate;
rateVel = 0;
} else {
throwRate *= Math.exp(-dt / 1.6);
const target = phraseRate(t) * (1 - clamp(Math.abs(throwRate) / 3)) + throwRate;
const acc = (target - rate) * 18 - rateVel * 5.5;
rateVel += acc * dt;
rate += rateVel * dt;
}
angle += rate * dt;
const accel = clamp(rateVel / 20, -1, 1);
const nod = 0.42 * Math.sin((t * 2 * Math.PI) / 17);
const roll = 0.32 * Math.sin((t * 2 * Math.PI) / 23 + 1);
// ---- ring: flat, ~2px violet, two breaths per 10s
const rr = (135 + 10 * Math.sin((t * 2 * Math.PI) / 5)) * u;
ctx.beginPath();
ctx.arc(cx, cy, rr, 0, Math.PI * 2);
ctx.strokeStyle = RING;
ctx.lineWidth = Math.max(1.25, 2 * u);
ctx.stroke();
// ---- occasional acts
nextAct -= dt;
if (nextAct <= 0) {
nextAct = 0.9 + Math.random() * 1.4;
const m = mates[(Math.random() * mates.length) | 0];
if (m.act === "none") {
const r = Math.random();
if (r < 0.4) {
m.act = "type";
m.text = PHRASES[(Math.random() * PHRASES.length) | 0];
} else if (r < 0.75) m.act = "click";
else {
m.nudge = 1;
m.nudgeDir = [(Math.random() - 0.5) * 0.5, (Math.random() - 0.5) * 0.5];
}
m.actT = 0;
}
}
const ca = Math.cos(angle);
const sa = Math.sin(angle);
const cn = Math.cos(nod);
const sn = Math.sin(nod);
const cr = Math.cos(roll);
const sr = Math.sin(roll);
const engage = p.inside ? 1 : 0;
const drawn: Drawn[] = [];
for (const m of mates) {
m.actT += dt;
if (m.act === "type" && m.actT > 2.4) m.act = "none";
if (m.act === "click" && m.actT > 0.7) m.act = "none";
m.nudge = Math.max(0, m.nudge - dt / 2.2);
const nd = Math.sin(m.nudge * Math.PI) * 1;
let [x, y, z] = m.v;
x += m.nudgeDir[0] * nd;
y += m.nudgeDir[1] * nd;
const len = Math.hypot(x, y, z);
x /= len;
y /= len;
z /= len;
// spin about y
const x1 = x * ca + z * sa;
const z1 = -x * sa + z * ca;
// nod about x
const y2 = y * cn - z1 * sn;
const z2 = y * sn + z1 * cn;
// roll about z
const x3 = x1 * cr - y2 * sr;
const y3 = x1 * sr + y2 * cr;
const depth = (z2 + 1) / 2; // 1 = front
// the swarm lags under acceleration, front drags most
const lag = -accel * 18 * u * depth;
const px = sx + x3 * R + lag;
const py = sy - y3 * R;
const ph = (11 + 20 * depth) * u;
let a = z2 >= 0 ? 1 : Math.max(0, 1 + z2 * 1.2);
let blur = (1 - depth) ** 1.3 * 3.4 * u;
// attention: nearby cursors resolve, come forward and look at you
const d = Math.hypot(p.x - (px + ph), p.y - (py + ph * 0.8));
const near = engage * clamp(1 - d / (h * 0.35));
m.attn = approach(m.attn, near, 6, dt);
a = a + (Math.min(1, a + 0.55) - a) * m.attn;
blur *= 1 - m.attn;
drawn.push({ m, x: px, y: py, z: z2, s: ph * (1 + 0.06 * m.attn), a, blur, look: m.attn });
}
drawn.sort((A, B) => A.z - B.z);
for (const d of drawn) {
if (d.a < 0.02) continue;
ctx.save();
ctx.globalAlpha = d.a;
let rot = 0;
if (d.look > 0.01) {
const want = Math.atan2(p.y - d.y, p.x - d.x) - POINTS;
let diff = want - rot;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
rot += diff * d.look * 0.9;
}
const m = d.m;
let label = m.name;
let press = 0;
if (m.act === "type") {
const n = Math.min(m.text.length, Math.floor((m.actT - 0.2) / 0.12));
if (n > 0) label = `${m.name}: ${m.text.slice(0, n)}${m.actT % 0.6 < 0.3 ? "|" : ""}`;
}
if (m.act === "click") {
const k = m.actT / 0.7;
press = Math.sin(clamp(k * 3) * Math.PI);
ctx.beginPath();
ctx.arc(d.x, d.y, d.s * (0.2 + k * 1.1), 0, Math.PI * 2);
ctx.strokeStyle = m.color;
ctx.globalAlpha = d.a * (1 - k) * 0.8;
ctx.lineWidth = Math.max(1, d.s * 0.08);
ctx.stroke();
ctx.globalAlpha = d.a;
}
if (d.blur > 0.5) {
// cheap defocus: render small, scale up with smoothing
if (!sprite) sprite = makeCanvas(420, 140);
const r = 1 / (1 + d.blur * 0.45);
const bx = d.x - d.s * 0.8;
const by = d.y - d.s * 0.8;
const bw = Math.min(420 / (r * dpr), d.s * 4 + label.length * d.s * 0.36);
const bh = Math.min(140 / (r * dpr), d.s * 2.6);
const sc = sprite.ctx;
sc.setTransform(1, 0, 0, 1, 0, 0);
sc.clearRect(0, 0, 420, 140);
sc.setTransform(r * dpr, 0, 0, r * dpr, -bx * r * dpr, -by * r * dpr);
drawMate(sc, d.x, d.y, d.s, m.color, m.dark, label, rot, press);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(sprite.c, 0, 0, bw * r * dpr, bh * r * dpr, bx, by, bw, bh);
} else {
drawMate(ctx, d.x, d.y, d.s, m.color, m.dark, label, rot, press);
}
ctx.restore();
}
// ---- you: one more of the cast, in front of the globe
if (p.inside) {
if (!me.init) {
me.x = p.x;
me.y = p.y;
me.init = true;
}
me.x = approach(me.x, p.x, 14, dt);
me.y = approach(me.y, p.y, 14, dt);
}
me.a = approach(me.a, p.inside ? 1 : 0, 8, dt);
if (!p.inside && me.a < 0.02) me.init = false;
if (me.a > 0.02) {
ctx.save();
ctx.globalAlpha = me.a;
drawMate(ctx, me.x, me.y, 31 * u, "#16161a", false, "You", 0, p.down ? 1 : 0);
ctx.restore();
}
},
};
}
export default function CursorSphere() {
const ref = useStage(createScene, 4);
return <div ref={ref} className="relative h-full w-full overflow-hidden bg-white" />;
}
Prompt
Build "Cursor Swarm Sphere", a cursor animation. What it does: Twenty-six named multiplayer cursors ride an invisible sphere around a breathing violet ring, growing at the front and blurring behind, occasionally typing or clicking; drag to spin it and your own cursor joins the swarm. 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).