Baktiyar Ablaikhan

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