Porcelain Dotted Desk Globe You Spin with Inertia with Flag Pins That Trail as It Turns

A white porcelain globe the size of a grapefruit, standing in a turned brass foot on a pale table. Its continents are rows of small blue dots pressed into the glaze, fine blue lines of latitude and longitude are scored round it, a brass cap marks the pole, and a window is reflected high on its left side. Five steel pins with coloured heads stand out of it, each flying a pennant. The globe arrives spinning, slows, and comes round to the first pin with a slight overshoot. After that it visits each pin in turn. Take hold of it and it follows your hand, then carries on with the speed you gave it until friction brings it back to a slow idle turn. The pennants swing to trail behind whichever way their pin is moving. On a phone the whole object scales to the screen and the place buttons wrap under it.

Code

import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";

/** The made-up company and its small world. */
const world = { name: "Small World No. 3", owner: "Aveline and Marrow, shippers" };

/** Flag pins. lat and lon are in degrees; each pin snaps to the nearest dot of land when the globe is made. */
const places = [
  { id: "aveline", name: "Port Aveline", role: "Head office", lat: 34, lon: 8, color: "#e2452c" },
  { id: "tessane", name: "Tessane", role: "Print works", lat: 8, lon: 96, color: "#f2b632" },
  { id: "marrow", name: "Marrow Quay", role: "Warehouse", lat: -22, lon: 178, color: "#1f8a5b" },
  { id: "ostrel", name: "Ostrel", role: "Studio", lat: 46, lon: 250, color: "#17171c" },
  { id: "fenwick", name: "Fenwick Sound", role: "Depot", lat: -6, lon: 318, color: "#ef7fa6" },
];

// Design pixels.
const LW = 640;
const LH = 600;
const R = 196; // radius of the globe
const CX = LW / 2;
const CY = 276;
const TILT = 0.36; // lean of the axis, in radians
const REST = 0.3; // how far the north pole tips toward you at rest
const IDLE = 0.17; // radians a second it turns when left alone
const GRID = 3; // degrees between dots
const DOT = 2.5;
const NEEDLE = 0.22;
const ASIDE = 0.6; // a chosen pin stops this far left of centre, in radians, so it is seen side on // pin length as a share of the radius
const SEA = 0.5; // raise for more ocean, lower for more land
const INK = ["#3b66ea", "#2146c0", "#152c80"]; // dot colour in light, half light and shade
const LIGHT = [-0.5, 0.6, 0.62];

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const rad = (d: number) => (d * Math.PI) / 180;

const hash3 = (x: number, y: number, z: number) => {
  let n = (x * 374761393 + y * 668265263 + z * 2147483647) | 0;
  n = (n ^ (n >>> 13)) * 1274126177;
  n = n ^ (n >>> 16);
  return ((n >>> 0) % 100000) / 100000;
};
const noise3 = (x: number, y: number, z: number) => {
  const xi = Math.floor(x);
  const yi = Math.floor(y);
  const zi = Math.floor(z);
  const s = (t: number) => t * t * (3 - 2 * t);
  const u = s(x - xi);
  const v = s(y - yi);
  const w = s(z - zi);
  const mix = (a: number, b: number, t: number) => a + (b - a) * t;
  const plane = (k: number) => mix(mix(hash3(xi, yi, zi + k), hash3(xi + 1, yi, zi + k), u), mix(hash3(xi, yi + 1, zi + k), hash3(xi + 1, yi + 1, zi + k), u), v);
  return mix(plane(0), plane(1), w);
};
/** Continents are invented: land is wherever four layers of noise add up past the SEA mark. */
const isLand = (x: number, y: number, z: number) => {
  let sum = 0;
  let amp = 0.5;
  let f = 1.45;
  for (let i = 0; i < 4; i++) {
    sum += amp * noise3(x * f + 5.3, y * f + 1.7, z * f + 9.1);
    amp *= 0.5;
    f *= 2.07;
  }
  return sum / 0.9375 > SEA && Math.abs(y) < 0.985;
};
const onSphere = (lat: number, lon: number) => {
  const c = Math.cos(rad(lat));
  return [c * Math.sin(rad(lon)), Math.sin(rad(lat)), c * Math.cos(rad(lon))];
};

function buildSprites(S: number) {
  const make = (w: number, h: number, draw: (c: CanvasRenderingContext2D) => void) => {
    const cv = document.createElement("canvas");
    cv.width = Math.ceil(w * S);
    cv.height = Math.ceil(h * S);
    const c = cv.getContext("2d")!;
    c.scale(S, S);
    draw(c);
    return cv;
  };
  const standTop = CY + R * 0.86;
  const standRx = R * 0.52;
  const standRy = standRx * 0.27;
  const footY = CY + R * 1.07;
  const footRx = R * 0.66;
  const footRy = footRx * 0.24;
  const brass = (c: CanvasRenderingContext2D, x0: number, x1: number) => {
    const g = c.createLinearGradient(x0, 0, x1, 0);
    [[0, "#6f5220"], [0.12, "#b08a3e"], [0.28, "#f6e3a6"], [0.4, "#c9a352"], [0.58, "#a07a30"], [0.74, "#e8cd86"], [0.9, "#8a6828"], [1, "#5c4318"]].forEach(([o, col]) => g.addColorStop(o as number, col as string));
    return g;
  };

  // everything behind the ball: shadow on the table and the turned brass foot
  const back = make(LW, LH, (c) => {
    c.save();
    c.translate(CX + R * 0.26, footY + 6);
    c.scale(1, 0.2);
    const sh = c.createRadialGradient(0, 0, R * 0.2, 0, 0, R * 1.18);
    sh.addColorStop(0, "rgba(60,48,30,0.42)");
    sh.addColorStop(0.5, "rgba(60,48,30,0.16)");
    sh.addColorStop(1, "rgba(60,48,30,0)");
    c.fillStyle = sh;
    c.fillRect(-R * 1.4, -R * 1.4, R * 2.8, R * 2.8);
    c.restore();
    c.save();
    c.translate(CX + 6, footY + 3);
    c.scale(1, 0.26);
    const contact = c.createRadialGradient(0, 0, footRx * 0.6, 0, 0, footRx * 1.22);
    contact.addColorStop(0, "rgba(40,30,16,0.7)");
    contact.addColorStop(1, "rgba(40,30,16,0)");
    c.fillStyle = contact;
    c.fillRect(-footRx * 1.3, -footRx * 1.3, footRx * 2.6, footRx * 2.6);
    c.restore();
    c.fillStyle = brass(c, CX - footRx, CX + footRx);
    c.beginPath();
    c.ellipse(CX, standTop, standRx, standRy, 0, Math.PI, 0, true);
    c.bezierCurveTo(CX + standRx - 2, standTop + 26, CX + footRx, footY - 14, CX + footRx, footY);
    c.ellipse(CX, footY, footRx, footRy, 0, 0, Math.PI);
    c.bezierCurveTo(CX - footRx, footY - 14, CX - standRx + 2, standTop + 26, CX - standRx, standTop);
    c.fill();
    // lathe lines and the bright lip at the bottom
    c.lineWidth = 0.9;
    for (const [y, rx, a] of [[standTop + 15, standRx + 2.5, 0.32], [footY - 9, footRx - 1.5, 0.4]] as const) {
      c.strokeStyle = `rgba(60,40,10,${a})`;
      c.beginPath();
      c.ellipse(CX, y, rx, rx * 0.25, 0, 0.08, Math.PI - 0.08);
      c.stroke();
      c.strokeStyle = "rgba(255,244,205,0.5)";
      c.beginPath();
      c.ellipse(CX, y + 1.1, rx, rx * 0.25, 0, 0.08, Math.PI - 0.08);
      c.stroke();
    }
    c.strokeStyle = "rgba(255,246,214,0.6)";
    c.lineWidth = 1.2;
    c.beginPath();
    c.ellipse(CX, footY - 0.6, footRx - 0.6, footRy, 0, 0.15, Math.PI - 0.15);
    c.stroke();
  });

  // the porcelain ball with its lamp fixed up and to the left
  const ball = make(LW, LH, (c) => {
    c.beginPath();
    c.arc(CX, CY, R, 0, Math.PI * 2);
    c.clip();
    const body = c.createRadialGradient(CX - R * 0.36, CY - R * 0.42, R * 0.05, CX - R * 0.12, CY - R * 0.14, R * 1.2);
    [[0, "#ffffff"], [0.26, "#fbf8f1"], [0.52, "#ebe5d8"], [0.74, "#d3ccbc"], [0.9, "#b2a997"], [1, "#958c7a"]].forEach(([o, col]) => body.addColorStop(o as number, col as string));
    c.fillStyle = body;
    c.fillRect(0, 0, LW, LH);
    // warm light bounced up off the table and the brass
    const bounce = c.createRadialGradient(CX + R * 0.2, CY + R * 1.05, 0, CX + R * 0.2, CY + R * 1.05, R * 0.95);
    bounce.addColorStop(0, "rgba(255,226,170,0.55)");
    bounce.addColorStop(1, "rgba(255,226,170,0)");
    c.fillStyle = bounce;
    c.fillRect(0, 0, LW, LH);
    const edge = c.createRadialGradient(CX, CY, R * 0.86, CX, CY, R);
    edge.addColorStop(0, "rgba(70,60,45,0)");
    edge.addColorStop(1, "rgba(70,60,45,0.3)");
    c.fillStyle = edge;
    c.fillRect(0, 0, LW, LH);
  });

  // glaze: a soft bloom and the sharp reflection of a window, laid over the dots
  const gloss = make(LW, LH, (c) => {
    c.beginPath();
    c.arc(CX, CY, R, 0, Math.PI * 2);
    c.clip();
    const bloom = c.createRadialGradient(CX - R * 0.4, CY - R * 0.46, 0, CX - R * 0.4, CY - R * 0.46, R * 0.5);
    bloom.addColorStop(0, "rgba(255,255,255,0.75)");
    bloom.addColorStop(0.4, "rgba(255,255,255,0.22)");
    bloom.addColorStop(1, "rgba(255,255,255,0)");
    c.fillStyle = bloom;
    c.fillRect(0, 0, LW, LH);
    c.save();
    c.translate(CX - R * 0.43, CY - R * 0.5);
    c.rotate(-0.72);
    c.fillStyle = "rgba(255,255,255,0.9)";
    for (const dx of [-13, 3]) {
      c.beginPath();
      c.ellipse(dx + 6, 0, 6.5, 17, 0, 0, Math.PI * 2);
      c.fill();
    }
    c.restore();
    // the brass foot mirrored faintly in the underside
    const under = c.createLinearGradient(0, CY + R * 0.6, 0, CY + R * 0.9);
    under.addColorStop(0, "rgba(190,150,70,0)");
    under.addColorStop(1, "rgba(190,150,70,0.32)");
    c.fillStyle = under;
    c.fillRect(0, CY + R * 0.6, LW, R * 0.4);
  });

  // the near lip of the foot, which sits in front of the ball
  const lip = make(LW, LH, (c) => {
    c.strokeStyle = "rgba(40,28,8,0.5)";
    c.lineWidth = 5;
    c.beginPath();
    c.ellipse(CX, standTop - 2.4, standRx, standRy, 0, 0.1, Math.PI - 0.1);
    c.stroke();
    c.strokeStyle = brass(c, CX - standRx, CX + standRx);
    c.lineWidth = 4.2;
    c.beginPath();
    c.ellipse(CX, standTop, standRx, standRy, 0, 0, Math.PI);
    c.stroke();
    c.strokeStyle = "rgba(255,248,220,0.75)";
    c.lineWidth = 0.9;
    c.beginPath();
    c.ellipse(CX, standTop - 1.4, standRx, standRy, 0, 0.2, Math.PI - 0.2);
    c.stroke();
  });

  const heads = places.map((p) =>
    make(22, 22, (c) => {
      c.scale(1.375, 1.375);
      const g = c.createRadialGradient(6, 5.4, 0.3, 8, 8, 6.2);
      g.addColorStop(0, "#ffffff");
      g.addColorStop(0.22, p.color);
      g.addColorStop(1, "rgba(0,0,0,0.9)");
      c.fillStyle = p.color;
      c.beginPath();
      c.arc(8, 8, 5.6, 0, Math.PI * 2);
      c.fill();
      c.globalAlpha = 0.62;
      c.fillStyle = g;
      c.fill();
      c.globalAlpha = 1;
      c.fillStyle = "rgba(255,255,255,0.92)";
      c.beginPath();
      c.ellipse(6.1, 5.6, 1.7, 1.1, -0.6, 0, Math.PI * 2);
      c.fill();
    }),
  );
  return { back, ball, gloss, lip, heads };
}

export default function PorcelainDottedDeskGlobe() {
  const reduce = !!useReducedMotion();
  const boxRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [near, setNear] = useState(0);
  const ctl = useRef({ yaw: 0, vel: 0, pitch: REST, pitchTo: REST, goto: -1, hold: 0, dragging: false, touched: false, paused: false, lastX: 0, lastY: 0, lastT: 0, scale: 1 });

  const go = (i: number, byHand: boolean) => {
    const c = ctl.current;
    if (byHand) c.touched = true;
    c.goto = i;
    c.hold = 0;
  };

  useEffect(() => {
    const box = boxRef.current;
    const canvas = canvasRef.current;
    if (!box || !canvas) return;
    const ctx = canvas.getContext("2d")!;
    const c = ctl.current;
    let S = 1;
    let sprites = buildSprites(1);
    const size = () => {
      c.scale = box.clientWidth / LW;
      const next = clamp(c.scale * Math.min(2, window.devicePixelRatio || 1), 0.75, 2);
      if (Math.abs(next - S) < 0.05 && canvas.width > 300) return;
      S = next;
      canvas.width = Math.round(LW * S);
      canvas.height = Math.round(LH * S);
      sprites = buildSprites(S);
    };
    size();
    const ro = new ResizeObserver(() => {
      size();
      if (reduce) frame(performance.now());
    });
    ro.observe(box);

    // dots of land, one ring of latitude at a time
    const pts: number[] = [];
    for (let lat = -87; lat <= 87; lat += GRID) {
      const n = Math.max(1, Math.round((360 * Math.cos(rad(lat))) / GRID));
      for (let k = 0; k < n; k++) {
        const p = onSphere(lat, (k + (Math.round(lat / GRID) % 2) * 0.5) * (360 / n));
        if (isLand(p[0], p[1], p[2])) pts.push(p[0], p[1], p[2]);
      }
    }
    const N = pts.length / 3;
    const land = new Float32Array(pts);
    // each pin moves to the nearest dot of land so it never stands in the sea
    const pins = places.map((pl) => {
      const want = onSphere(pl.lat, pl.lon);
      let best = 0;
      let bestD = -2;
      for (let i = 0; i < N; i++) {
        const d = land[i * 3] * want[0] + land[i * 3 + 1] * want[1] + land[i * 3 + 2] * want[2];
        if (d > bestD) {
          bestD = d;
          best = i;
        }
      }
      const p = [land[best * 3], land[best * 3 + 1], land[best * 3 + 2]];
      return { p, lon: Math.atan2(p[0], p[2]), lat: Math.asin(p[1]), fx: 1, fy: 0, fvx: 0, fvy: 0, px: 0, py: 0, seen: false };
    });
    const bins = [0, 1, 2].map(() => new Float32Array(N * 4));
    // hairlines scored into the glaze: the equator, four parallels and six meridians
    const lines: { pts: number[][]; a: number }[] = [];
    for (const lat of [0, 30, -30, 60, -60]) {
      const pts: number[][] = [];
      for (let lon = 0; lon <= 360; lon += 4) pts.push(onSphere(lat, lon));
      lines.push({ pts, a: lat === 0 ? 0.34 : 0.15 });
    }
    for (let lon = 0; lon < 360; lon += 30) {
      const pts: number[][] = [];
      for (let lat = -84; lat <= 84; lat += 4) pts.push(onSphere(lat, lon));
      lines.push({ pts, a: 0.15 });
    }
    const pole = [0, 1, 0];

    // spin up at the start, then come round to the first pin
    c.yaw = pins[0].lon * -1 - 2.6;
    c.vel = reduce ? 0 : 3.3;
    if (reduce) {
      c.yaw = -pins[0].lon - ASIDE;
      c.pitch = c.pitchTo = REST;
    }
    let raf = 0;
    let seen = true;
    let stopped = false;
    let last = 0;
    let born = 0;
    let shown = -1;
    let nextTour = 0;
    let rot = [1, 0, 0, 0, 1, 0, 0, 0, 1];
    const turn = (p: ArrayLike<number>, o: number, out: number[]) => {
      out[0] = rot[0] * p[o] + rot[1] * p[o + 1] + rot[2] * p[o + 2];
      out[1] = rot[3] * p[o] + rot[4] * p[o + 1] + rot[5] * p[o + 2];
      out[2] = rot[6] * p[o] + rot[7] * p[o + 1] + rot[8] * p[o + 2];
    };
    const v = [0, 0, 0];

    function frame(now: number) {
      raf = 0;
      if (!born) born = now;
      const dt = clamp((now - last) / 1000 || 0.016, 0.001, 0.05);
      last = now;
      const age = (now - born) / 1000;

      // motion: dragging sets the speed; left alone, friction eases it to a slow idle turn
      if (!c.dragging && !reduce) {
        if (c.goto < 0 && !c.touched && !c.paused && age > 0.95 && now > nextTour) {
          c.goto = shown < 0 ? 0 : (shown + 1) % pins.length;
          c.hold = 0;
        }
        if (c.goto >= 0) {
          const pin = pins[c.goto];
          let d = -pin.lon - ASIDE - c.yaw;
          d = ((((d + Math.PI) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) - Math.PI;
          // a spring that overshoots a little, like a globe caught by hand
          c.vel += (d * 15 - c.vel * 5.2) * dt;
          c.pitchTo = clamp(pin.lat * 0.75 - 0.12, -0.3, 0.7);
          if (Math.abs(d) < 0.02 && Math.abs(c.vel) < 0.04) {
            c.hold += dt;
            if (c.hold > (c.touched ? 4 : 2.6)) {
              shown = c.goto;
              c.goto = -1;
              c.pitchTo = REST;
              nextTour = now + 1500;
            }
          }
        } else c.vel += (IDLE - c.vel) * (1 - Math.exp(-dt / 0.75));
        c.yaw += c.vel * dt;
        c.pitch += (c.pitchTo - c.pitch) * (1 - Math.exp(-dt / 0.35));
      } else if (reduce && c.goto >= 0) {
        c.yaw = -pins[c.goto].lon - ASIDE;
        c.pitch = clamp(pins[c.goto].lat * 0.75 - 0.12, -0.3, 0.7);
        c.goto = -1;
      }

      const cy = Math.cos(c.yaw);
      const sy = Math.sin(c.yaw);
      const ct = Math.cos(TILT);
      const st = Math.sin(TILT);
      const cp = Math.cos(c.pitch);
      const sp = Math.sin(c.pitch);
      // spin about the axis, lean the axis over, then tip the pole toward the viewer
      const a = [cy, 0, sy, 0, 1, 0, -sy, 0, cy];
      const b = [ct * a[0] + st * a[3], ct * a[1] + st * a[4], ct * a[2] + st * a[5], -st * a[0] + ct * a[3], -st * a[1] + ct * a[4], -st * a[2] + ct * a[5], a[6], a[7], a[8]];
      rot = [b[0], b[1], b[2], cp * b[3] - sp * b[6], cp * b[4] - sp * b[7], cp * b[5] - sp * b[8], sp * b[3] + cp * b[6], sp * b[4] + cp * b[7], sp * b[5] + cp * b[8]];

      ctx.setTransform(S, 0, 0, S, 0, 0);
      ctx.clearRect(0, 0, LW, LH);
      ctx.drawImage(sprites.back, 0, 0, LW, LH);

      // pins: work out where each stands, and let its flag trail behind the way it is moving
      let front = -1;
      let frontZ = 0.2;
      const laid = pins.map((pin, i) => {
        turn(pin.p, 0, v);
        const bx = CX + v[0] * R;
        const by = CY - v[1] * R;
        const tx = CX + v[0] * R * (1 + NEEDLE);
        const ty = CY - v[1] * R * (1 + NEEDLE);
        const z = v[2];
        if (z > frontZ) {
          frontZ = z;
          front = i;
        }
        const vx = pin.seen ? (tx - pin.px) / dt : 0;
        const vy = pin.seen ? (ty - pin.py) / dt : 0;
        pin.px = tx;
        pin.py = ty;
        pin.seen = true;
        const flutter = reduce ? 0 : Math.sin(now / 190 + i * 1.7) * 0.1;
        let wx = 1 - vx * 0.011;
        let wy = 0.12 + flutter - vy * 0.011;
        const wl = Math.hypot(wx, wy) || 1;
        wx /= wl;
        wy /= wl;
        if (reduce) {
          pin.fx = wx;
          pin.fy = wy;
        } else {
          pin.fvx += ((wx - pin.fx) * 90 - pin.fvx * 9) * dt;
          pin.fvy += ((wy - pin.fy) * 90 - pin.fvy * 9) * dt;
          pin.fx += pin.fvx * dt;
          pin.fy += pin.fvy * dt;
        }
        return { i, bx, by, tx, ty, z, nx: v[0], ny: v[1] };
      });
      const drawPin = (q: (typeof laid)[number]) => {
        const pin = pins[q.i];
        const hx = q.tx - q.bx;
        const hy = q.ty - q.by;
        ctx.lineCap = "round";
        ctx.strokeStyle = "#6d727a";
        ctx.lineWidth = 2.3;
        ctx.beginPath();
        ctx.moveTo(q.bx, q.by);
        ctx.lineTo(q.tx, q.ty);
        ctx.stroke();
        ctx.strokeStyle = "#eef1f4";
        ctx.lineWidth = 0.7;
        ctx.beginPath();
        ctx.moveTo(q.bx - 0.4, q.by - 0.4);
        ctx.lineTo(q.tx - 0.4, q.ty - 0.4);
        ctx.stroke();
        // the flag: hoisted on the outer part of the needle, flying along (fx, fy)
        let m1x = q.bx + hx * 0.42;
        let m1y = q.by + hy * 0.42;
        const m2x = q.bx + hx * 0.86;
        const m2y = q.by + hy * 0.86;
        if (Math.hypot(m2x - m1x, m2y - m1y) < 12) {
          m1x = m2x;
          m1y = m2y + 12;
        }
        const len = 31;
        const mx = (m1x + m2x) / 2;
        const my = (m1y + m2y) / 2;
        const ex = mx + pin.fx * len;
        const ey = my + pin.fy * len;
        const wave = reduce ? 1.2 : Math.sin(now / 130 + q.i * 2.3) * 2.2;
        const kx = -pin.fy * wave;
        const ky = pin.fx * wave;
        ctx.beginPath();
        ctx.moveTo(m2x, m2y);
        ctx.quadraticCurveTo((m2x + ex) / 2 + kx, (m2y + ey) / 2 + ky, ex, ey - 1.5);
        ctx.lineTo(ex, ey + 1.5);
        ctx.quadraticCurveTo((m1x + ex) / 2 + kx, (m1y + ey) / 2 + ky, m1x, m1y);
        ctx.closePath();
        ctx.fillStyle = places[q.i].color;
        ctx.fill();
        const sh = ctx.createLinearGradient(mx, my, ex, ey);
        sh.addColorStop(0, "rgba(255,255,255,0.28)");
        sh.addColorStop(0.45, "rgba(0,0,0,0)");
        sh.addColorStop(0.7, "rgba(0,0,0,0.2)");
        sh.addColorStop(1, "rgba(255,255,255,0.1)");
        ctx.fillStyle = sh;
        ctx.fill();
        ctx.drawImage(sprites.heads[q.i], q.tx - 11, q.ty - 11, 22, 22);
      };
      laid.filter((q) => q.z <= 0).forEach(drawPin);

      ctx.drawImage(sprites.ball, 0, 0, LW, LH);

      // the dots, sorted into three strengths of blue by how much lamp reaches them
      const count = [0, 0, 0];
      for (let i = 0; i < N; i++) {
        turn(land, i * 3, v);
        if (v[2] <= 0.02) continue;
        const lit = v[0] * LIGHT[0] + v[1] * LIGHT[1] + v[2] * LIGHT[2];
        const k = lit > 0.42 ? 0 : lit > -0.05 ? 1 : 2;
        const o = count[k] * 4;
        bins[k][o] = CX + v[0] * R;
        bins[k][o + 1] = CY - v[1] * R;
        // a round dot on a ball is seen as an ellipse, squashed along the line out from the centre
        bins[k][o + 2] = DOT * Math.max(0.22, v[2]);
        bins[k][o + 3] = Math.atan2(-v[1], v[0]);
        count[k]++;
      }
      // a pale crescent under each dot makes it read as pressed into the glaze
      ctx.fillStyle = "rgba(255,255,255,0.7)";
      ctx.beginPath();
      for (let k = 0; k < 3; k++)
        for (let i = 0; i < count[k]; i++) {
          const o = i * 4;
          ctx.moveTo(bins[k][o] + 0.8, bins[k][o + 1] + 0.9);
          ctx.ellipse(bins[k][o] + 0.8, bins[k][o + 1] + 0.9, bins[k][o + 2], DOT, bins[k][o + 3], 0, Math.PI * 2);
        }
      ctx.fill();
      for (let k = 0; k < 3; k++) {
        ctx.fillStyle = INK[k];
        ctx.beginPath();
        for (let i = 0; i < count[k]; i++) {
          const o = i * 4;
          ctx.moveTo(bins[k][o], bins[k][o + 1]);
          ctx.ellipse(bins[k][o], bins[k][o + 1], bins[k][o + 2], DOT, bins[k][o + 3], 0, Math.PI * 2);
        }
        ctx.fill();
      }
      ctx.lineWidth = 0.9;
      for (const line of lines) {
        ctx.strokeStyle = `rgba(33,70,192,${line.a})`;
        ctx.beginPath();
        let pen = false;
        for (const e of line.pts) {
          turn(e, 0, v);
          if (v[2] <= 0.03) {
            pen = false;
            continue;
          }
          pen ? ctx.lineTo(CX + v[0] * R, CY - v[1] * R) : ctx.moveTo(CX + v[0] * R, CY - v[1] * R);
          pen = true;
        }
        ctx.stroke();
      }
      // a brass cap where the axis comes out at the north pole
      turn(pole, 0, v);
      if (v[2] > 0.05) {
        const ang = Math.atan2(-v[1], v[0]);
        const x = CX + v[0] * R;
        const y = CY - v[1] * R;
        ctx.fillStyle = "rgba(60,45,20,0.35)";
        ctx.beginPath();
        ctx.ellipse(x + 1.2, y + 1.6, 7.5 * Math.max(0.25, v[2]), 7.5, ang, 0, Math.PI * 2);
        ctx.fill();
        const cap = ctx.createLinearGradient(x - 6, y - 6, x + 6, y + 6);
        cap.addColorStop(0, "#fbeab4");
        cap.addColorStop(0.5, "#c59c4a");
        cap.addColorStop(1, "#7a5a22");
        ctx.fillStyle = cap;
        ctx.beginPath();
        ctx.ellipse(x, y, 6.6 * Math.max(0.25, v[2]), 6.6, ang, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = "#5d4418";
        ctx.beginPath();
        ctx.ellipse(x, y, 2 * Math.max(0.25, v[2]), 2, ang, 0, Math.PI * 2);
        ctx.fill();
      }

      // the shadow each near pin lays across the porcelain, then the pins themselves
      const near = laid.filter((q) => q.z > 0).sort((p, q) => p.z - q.z);
      for (const q of near) {
        turn(pins[q.i].p, 0, v);
        const sx = v[0] * (1 + NEEDLE) - LIGHT[0] * NEEDLE * 0.9;
        const syy = v[1] * (1 + NEEDLE) - LIGHT[1] * NEEDLE * 0.9;
        const sz = v[2] * (1 + NEEDLE) - LIGHT[2] * NEEDLE * 0.9;
        const sl = Math.hypot(sx, syy, sz);
        if (sz / sl < 0.05) continue;
        const g = ctx.createLinearGradient(q.bx, q.by, CX + (sx / sl) * R, CY - (syy / sl) * R);
        g.addColorStop(0, "rgba(60,50,35,0.42)");
        g.addColorStop(1, "rgba(60,50,35,0)");
        ctx.strokeStyle = g;
        ctx.lineWidth = 2.6;
        ctx.lineCap = "round";
        ctx.beginPath();
        ctx.moveTo(q.bx, q.by);
        ctx.lineTo(CX + (sx / sl) * R, CY - (syy / sl) * R);
        ctx.stroke();
      }
      ctx.drawImage(sprites.gloss, 0, 0, LW, LH);
      near.forEach(drawPin);
      ctx.drawImage(sprites.lip, 0, 0, LW, LH);

      const named = c.goto >= 0 ? c.goto : front;
      if (named >= 0 && named !== shownName) {
        shownName = named;
        setNear(named);
      }
      const deg = Math.round((((((c.yaw * 180) / Math.PI) % 360) + 360) % 360) / 5) * 5;
      if (deg !== lastDeg) {
        lastDeg = deg;
        box.setAttribute("aria-valuenow", String(deg));
      }
      if (!reduce && seen && !document.hidden && !stopped) raf = requestAnimationFrame(frame);
    }
    let shownName = -1;
    let lastDeg = -1;
    const kick = () => {
      if (!raf && !stopped) raf = requestAnimationFrame(frame);
    };
    kick();
    const io = new IntersectionObserver(([e]) => {
      seen = e.isIntersecting;
      if (seen) kick();
    });
    io.observe(box);
    const onVis = () => !document.hidden && kick();
    document.addEventListener("visibilitychange", onVis);
    // with motion reduced there is no loop: redraw only when something asks for it
    const poll = reduce ? window.setInterval(() => (c.goto >= 0 || c.dragging || c.vel !== 0) && ((c.vel = 0), kick()), 100) : 0;
    return () => {
      stopped = true;
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      if (poll) window.clearInterval(poll);
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [reduce]);

  const onDown = (e: React.PointerEvent) => {
    if (e.button !== 0) return;
    const c = ctl.current;
    c.dragging = true;
    c.touched = true;
    c.goto = -1;
    c.pitchTo = c.pitch;
    c.lastX = e.clientX;
    c.lastY = e.clientY;
    c.lastT = performance.now();
    c.vel = reduce ? 1 : 0;
    e.currentTarget.setPointerCapture(e.pointerId);
  };
  const onDrag = (e: React.PointerEvent) => {
    const c = ctl.current;
    if (!c.dragging) return;
    const now = performance.now();
    const k = 1 / (R * c.scale);
    const d = (e.clientX - c.lastX) * k;
    c.yaw += d;
    c.pitch = clamp(c.pitch + (e.clientY - c.lastY) * k, -0.5, 0.95);
    c.pitchTo = c.pitch;
    if (!reduce) c.vel = c.vel * 0.55 + (d / Math.max(0.004, (now - c.lastT) / 1000)) * 0.45;
    else c.vel = 1;
    c.lastX = e.clientX;
    c.lastY = e.clientY;
    c.lastT = now;
  };
  const onUp = () => {
    const c = ctl.current;
    if (!c.dragging) return;
    c.dragging = false;
    if (performance.now() - c.lastT > 90) c.vel = reduce ? 1 : 0;
    c.vel = clamp(c.vel, -14, 14);
  };
  const onKey = (e: React.KeyboardEvent) => {
    const c = ctl.current;
    const step = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
    if (!step) return;
    e.preventDefault();
    c.touched = true;
    c.goto = -1;
    if (reduce) {
      c.yaw += step[0] * 0.26;
      c.vel = 1;
    } else c.vel += step[0] * 1.8;
    c.pitchTo = clamp(c.pitchTo + step[1] * 0.2, -0.5, 0.95);
    if (reduce) c.pitch = c.pitchTo;
  };

  return (
    <section className="relative overflow-hidden bg-[#e7e3da]">
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(60% 56% at 44% 38%, #f6f3ec 0%, #e9e5dc 48%, #d6d0c3 100%)" }} />
      <div className="relative mx-auto w-full max-w-[720px] px-2 pt-4 sm:pt-6">
        <div
          ref={boxRef}
          role="slider"
          tabIndex={0}
          aria-label={`Spin the globe of ${world.name}. Arrow keys turn and tip it.`}
          aria-valuemin={0}
          aria-valuemax={360}
          aria-valuenow={0}
          aria-valuetext={`${places[near].name} is nearest`}
          onPointerDown={onDown}
          onPointerMove={onDrag}
          onPointerUp={onUp}
          onPointerCancel={onUp}
          onPointerEnter={() => (ctl.current.paused = true)}
          onPointerLeave={() => (ctl.current.paused = false)}
          onKeyDown={onKey}
          className="relative w-full cursor-grab touch-pan-y select-none rounded-[28px] outline-none focus-visible:ring-2 focus-visible:ring-[#2146c0] active:cursor-grabbing"
          style={{ aspectRatio: `${LW} / ${LH}` }}
        >
          <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 h-full w-full" />
        </div>
      </div>

      <div className="relative flex flex-col gap-3 px-5 pb-6 pt-1 sm:flex-row sm:items-end sm:justify-between sm:px-8 sm:pb-8">
        <p className="font-mono text-[11px] uppercase leading-relaxed tracking-[0.18em] text-stone-600">
          <span className="text-stone-900">
            {world.name} / {world.owner}
          </span>
          <span className="block" aria-live="polite">
            {places[near].name}: {places[near].role}
          </span>
        </p>
        <div className="flex flex-wrap gap-2">
          {places.map((p, i) => (
            <button
              key={p.id}
              type="button"
              aria-pressed={near === i}
              onClick={() => go(i, true)}
              className={`flex h-10 items-center gap-2 rounded-full px-3.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[#2146c0] focus-visible:ring-offset-2 focus-visible:ring-offset-[#e7e3da] ${near === i ? "bg-stone-900 text-white" : "bg-white/70 text-stone-800 hover:bg-white"}`}
            >
              <span aria-hidden="true" className="h-2.5 w-2.5 rounded-full ring-1 ring-black/10" style={{ background: p.color }} />
              {p.name}
            </button>
          ))}
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/porcelain-dotted-desk-globe-you-spin-with-inertia-with-flag-pins-that-trail-as-it-turns.json

When to use it

How to customize it

Accessibility

Questions

How does the spin keep going after I let go?

While you drag, the code keeps a running average of how fast your hand is turning the ball. On release that speed is left in place and decays toward `IDLE` with a time constant of three quarters of a second. If you held still for a moment before letting go, the speed is cleared first, so the globe stays where you put it.

Is the map of the real Earth?

No. The land is generated: each candidate dot is tested against four layers of 3D noise and kept if the sum passes `SEA`. That avoids shipping coastline data and means the world is yours. For the real continents you would replace `isLand` with a lookup into a land mask you have the right to use.

How are pins on the far side handled?

Drawing happens in layers. Pins whose base is on the far half are drawn first, then the ball, then the dots and glaze, then the near pins sorted from back to front. A pin just over the horizon therefore shows its head above the edge while its foot is hidden, which is what a real one does.

What makes the flags swing?

Each flag has a direction held by a damped spring. Its target is a gentle breeze to the right minus the screen velocity of the pin's tip, so a fast pin drags its flag out behind it. When the globe slows, the spring lets the flag swing back and settle.

How heavy is the drawing loop?

The ball, glaze, foot and pin heads are painted once into sprites. Each frame rotates the dots with one 3 by 3 matrix, sorts the visible ones into three colour groups and fills each group as a single path. The loop pauses when the globe is off screen or the tab is hidden.

Related: Folded Paper Street Map That Unfolds Panel by Panel with Creases and a Pin That Drops In, Steel Wristwatch with a Sweeping Seconds Hand, Date Window and a Crown You Pull to Set the Time, Brass Pendulum Wall Clock That Beats Once a Second in True Time and Throws a Moving Shadow.

More content sections or all free UI components. Start for free to have a full site built around it.

More in Content

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap