liten

Pixel Globe

A pixel-art world globe rendered as a mosaic of top-lit tiles, with continents scrolling across a still grid.

The sphere is a fixed grid of square tiles, and every frame each tile is mapped back through the rotation to the patch of Earth beneath it, so the continents scroll across a still mosaic instead of a bitmap being spun. It's lit from above, but the shade is quantized into a few hard steps: the crown is bright, the base falls to ink, with no gradients anywhere. Drag to spin it, and release for a little inertia.

Installation

Complete the shared Setup first, then copy the component into components/ui/pixel-globe.tsx.

components/ui/pixel-globe.tsx
'use client';import * as React from 'react';import { cn } from '@/lib/cn';const MASK_W = 256;const MASK_H = 128;const LAND_MASK =  '////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////Dj///////6n////////////////////////////////8AAACAAAAgP8AP////////////////////////////8AAAP/gAAAAAAA+gf//////////////////////////wAAAfQAAAAAAAADg////////////////////g//////AAAAQAAAAGYAAAf8H//////////////////4AJ////8AAAAAAAAD4AAP//4AAfn/////////x/////4AB////wAAAAAAAAcAAL///gKAef////////+H/////8zD///+AAAAAAAAHAHH/////8A/AAO//P/+AAM//////+H///wAAAAAIAAcA//////////uAA/gP//z/5//////4f//+AAAAB/8AAB73//////////9/+D////////////w///gAAAAf//mf//f////////////+H////////////D//gAAAAD///f////////////////5////////////4P/4A/AAA/3+P////////////////CAf///////+8T/Af8AD8AAH+f/////////////////4AP////////ghTwA/gAAAAB/n//////////////////wA////////8APwAB8AAAAAP+f///////////////z/gAB/sX/////gA/mAAwAAAAA/4///////////////8OAAABeAD/////AD/8AAAAAAMBPB/////////////+ABwAAABkAH/////gH/wAAAAAAwDcf/////////////gAfAAAAQAAH/////wf/gAAAAADgPB/////////////4AB4AAAAAAAH/////3//wAAAAB3A///////////////8AHAAAAAAAAP/////P//gAAAAHeP///////////////8AYAAAAAAAAf///////8AAAAAB7////////////////wBAAAAAAAAA///////+YAAAAABf///////////////9gAAAAAAAAAB////v//DwAAAAAf/////5//////////0AAAAAAAAAAD///+P/8HAAAAAA/////+B/////////+QAAAAAAAAAAP////v/+AAAAAAB///9nwHf////////wAAAAAAAAAAB////2/9gAAAAAAH/v/ifj9////////+OAAAAAAAAAAH//////AAAAAAAP8nP8AfH/////////w4AAAAAAAAAAf/////8AAAAAAB/wnP3x+P////////4AAAAAAAAAAAA//////AAAAAAAH+CG7//4/////////AYAAAAAAAAAAD/////4AAAAAAAfwARn//j///////wYBgAAAAAAAAAAH/////AAAAAAAA+AmGf//P///////4wMAAAAAAAAAAAP////8AAAAAAAAj/AAF//////////DHwAAAAAAAAAAA/////gAAAAAAAH/8AAH/////////4J8AAAAAAAAAAAA////8AAAAAAAA//8GAf/////////wOAAAAAAAAAAAAB////AAAAAAAAH//8/L//////////gQAAAAAAAAAAAAG//+8AAAAAAAAf//////////////+AAAAAAAAAAAAAAP/8AYAAAAAAAD//////+////////4AAAAAAAAAAAAAAX/gBgAAAAAAA/////9/5////////AAAAAAAAAAAAAAAv+ACAAAAAAAD/////7/wf//////4AAAAAAAAAAAAAADf4AAAAAAAAAf/////v/uB//////IAAAAAAAAAAAAAAE/gBgAAAAAAD//////f/8B/////5gAAAAAAAAAAAAAAB+ADwAAAAAAP/////8//wH//f/+AAAAAAAAAAAAAAAAH4MBgAAAAAA//////7//AH/w/8gAAAAAAAAAAAAAAAAfxwA4AAAAAD//////n/4AP+B/mAAAAAAAAAAAAAAAAAf+ABAAAAAAP//////P/AA/gH/AGAAAAAAAAAAAAAAAAP4AAAAAAAA//////8/wAD8AX+AYAAAAAAAAAAAAAAAAD+AAAAAAAD//////78AAPgAf8BgAAAAAAAAAAAAAAAAD4AAAAAAAP///////AAAeAA/wDAAAAAAAAAAAAAAAAADgAAAAAAA///////gAAB4ACfADAAAAAAAAAAAAAAAAAGD+wAAAAB///////+AADgAI4AQAAAAAAAAAAAAAAAAAP//gAAAAD///////wAANAAhABQAAAAAAAAAAAAAAAAAb//AAAAAH///////AAAMADAAHAAAAAAAAAAAAAAAAAAH//AAAAAP//////4AAAwAGAMMAAAAAAAAAAAAAAAAAAf//gAAAAOw/////gAAAADcB4AAAAAAAAAAAAAAAAAAB///AAAAAAAf///8AAAAAOwPAAAAAAAAAAAAAAAAAAAP//8AAAAAAB////gAAAAAfB8AAAAAAAAAAAAAAAAAAB///4AAAAAAP///4AAAAAA8fzoAAAAAAAAAAAAAAAAAP///4AAAAAA//+/AAAAAABx/MGAAAAAAAAAAAAAAAAA////4AAAAAD//78AAAAAADz7gNgAAAAAAAAAAAAAAAB////+AAAAAH///gAAAAAAPHuO/4AAAAAAAAAAAAAAAP////8AAAAAP//8AAAAAAAYAcA/wAAAAAAAAAAAAAAAf////4AAAAAf//wAAAAAAAcAAA/sAAAAAAAAAAAAAAB/////gAAAAB///AAAAAAAA+AAD+AAAAAAAAAAAAAAAD////+AAAAAH//8AAAAAAAAADADMAAAAAAAAAAAAAAAP////wAAAAAP//4AAAAAAAAAAAAYAAAAAAAAAAAAAAAf///+AAAAAA///gAAAAAAAAAA4QAAAAAAAAAAAAAAAA////4AAAAAH//+DAAAAAAAAAPjAAAAAAAAAAAAAAAAD////gAAAAA///4cAAAAAAAAH+GAAAAAAAAAAAAAAAAH///8AAAAAD///jwAAAAAAAA/88AAAAAAAAAAAAAAAAH///wAAAAAP//4fAAAAAAAAH//wAAAgAAAAAAAAAAAAP///AAAAAAf//B4AAAAAAAA///gAAAAAAAAAAAAAAAA///4AAAAAB//4HgAAAAAAAf///AAAAAAAAAAAAAAAAD///gAAAAAD//gcAAAAAAAD///+AEAAAAAAAAAAAAAAP//8AAAAAAP//BwAAAAAAAf///8AAAAAAAAAAAAAAAA//+AAAAAAA//4HAAAAAAAB////wAAAAAAAAAAAAAAAH//wAAAAAAD//AAAAAAAAAH////gAAAAAAAAAAAAAAAf//AAAAAAAH/4AAAAAAAAAf///+AAAAAAAAAAAAAAAB//4AAAAAAAf/gAAAAAAAAB////4AAAAAAAAAAAAAAAH//AAAAAAAA/8AAAAAAAAAD////gAAAAAAAAAAAAAAAf/4AAAAAAAB/gAAAAAAAAAP///+AAAAAAAAAAAAAAAB//gAAAAAAAH8AAAAAAAAAA/gf/wAAAAAAAAAAAAAAAP/8AAAAAAAAdAAAAAAAAAADgAv/AAAAAAAAAAAAAAAA//AAAAAAAAAAAAAAAAAAAAAAAf4AAQAAAAAAAAAAAAD/8AAAAAAAAAAAAAAAAAAAAAAB/gAAgAAAAAAAAAAAAP/AAAAAAAAAAAAAAAAAAAAAAAB4AADgAAAAAAAAAAAB/wAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAH+AAAAAAAAAAAAAAAAAAAAAAAAHAADAAAAAAAAAAAAAfwAAAAAAAAAAAAAAAAAAAAAAAAYAAcAAAAAAAAAAAAA/AAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAHwAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAfgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAHwAAAAAAAAAAAAAAAAAAAAAA8AAAAAAAAAAAAAAAAAg/gGB8AAAAAAAAAAAAAAAAAAfgAAAAAAAAAAAAfwAD///////8AAAAAAAAAAAAAAAAB/AAAAAAAAAAAA///8f////////4AAAAAAAAAAAAAAD/4AAAAAAAAPAf///////////////AAAAAAAAAAAAAAP/gAAAAA/////////////////////8AAAAAAAAAD/4D//AAAAA//////////////////////8AAAAABv/3P////4AAAAP//////////////////////wAAAA//////////4AAAH//////////////////////wAAAB///////////+AAH//////////////////////+AMA//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////w==';function decodeMask(): Uint8Array {  if (typeof atob === 'undefined') return new Uint8Array((MASK_W * MASK_H) / 8);  const bin = atob(LAND_MASK);  const out = new Uint8Array(bin.length);  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);  return out;}const MASK = decodeMask();const clampN = (n: number, lo: number, hi: number) =>  n < lo ? lo : n > hi ? hi : n;function isLandPoint(x: number, y: number, z: number) {  const phi = Math.asin(clampN(y, -1, 1));  const cp = Math.cos(phi);  if (Math.abs(cp) < 1e-6) return 0;  let theta = Math.acos(clampN(-x / cp, -1, 1));  if (z < 0) theta = -theta;  let u = ((theta * 0.5) / Math.PI) % 1;  if (u < 0) u += 1;  let v = (-(phi / Math.PI + 0.5)) % 1;  if (v < 0) v += 1;  const px = Math.min(MASK_W - 1, (u * MASK_W) | 0);  const py = Math.min(MASK_H - 1, (v * MASK_H) | 0);  const i = py * MASK_W + px;  return (MASK[i >> 3] >> (7 - (i & 7))) & 1;}const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);const smooth = (a: number, b: number, x: number) => {  const t = clamp01((x - a) / (b - a));  return t * t * (3 - 2 * t);};export type PixelGlobeProps = {  accent?: string;  speed?: number;  resolution?: number;  gap?: number;  interactive?: boolean;  dark?: boolean;  mask?: boolean;  className?: string;};export function PixelGlobe({  accent = '#3ecf8e',  speed = 0.12,  resolution = 62,  gap = 0.1,  interactive = true,  dark,  mask = true,  className,}: PixelGlobeProps) {  const canvasRef = React.useRef<HTMLCanvasElement>(null);  React.useEffect(() => {    const canvas = canvasRef.current;    if (!canvas) return;    const ctx = canvas.getContext('2d');    if (!ctx) return;    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;    let isDark = dark ?? document.documentElement.classList.contains('dark');    let themeObserver: MutationObserver | null = null;    if (dark === undefined) {      themeObserver = new MutationObserver(() => {        isDark = document.documentElement.classList.contains('dark');      });      themeObserver.observe(document.documentElement, {        attributes: true,        attributeFilter: ['class'],      });    }    const accentRgbArr = hexToRgb(accent).split(',').map(Number);    const TILT = 0.34;    const cT = Math.cos(TILT);    const sT = Math.sin(TILT);    let raf = 0;    let w = 0;    let h = 0;    let dpr = 1;    let cx = 0;    let cy = 0;    let R = 0;    let cell = 6;    let phi = -1.6;    let vel = 0;    let dragging = false;    let lastX = 0;    const resize = () => {      const rect = canvas.getBoundingClientRect();      dpr = Math.min(window.devicePixelRatio || 1, 2);      w = rect.width;      h = rect.height;      canvas.width = Math.max(1, Math.round(w * dpr));      canvas.height = Math.max(1, Math.round(h * dpr));      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);      cx = w / 2;      cy = h / 2;      R = (Math.min(w, h) / 2) * 0.92;      cell = Math.max(3, Math.round((2 * R) / resolution));      ctx.imageSmoothingEnabled = false;    };    const draw = (t: number, dt: number) => {      if (!dragging) {        phi += (speed + vel) * dt;        vel *= 0.94;        if (Math.abs(vel) < 0.0003) vel = 0;      }      ctx.clearRect(0, 0, w, h);      ctx.globalCompositeOperation = 'source-over';      ctx.globalAlpha = 1;      const cP = Math.cos(phi);      const sP = Math.sin(phi);      const inkArr = isDark ? [255, 255, 255] : [23, 23, 23];      const ink = `${inkArr[0]},${inkArr[1]},${inkArr[2]}`;      const inset = gap > 0 ? Math.max(1, Math.round(cell * gap)) : 0;      const size = Math.max(1, cell - inset);      const nCells = Math.ceil(R / cell) + 1;      const gx0 = cx - nCells * cell;      const gy0 = cy - nCells * cell;      const gx1 = cx + nCells * cell;      const gy1 = cy + nCells * cell;      const accArr = accentRgbArr;      const STEPS = 7;      const MAX_MIX = 0.72;      const landColR: number[] = [];      const landColG: number[] = [];      const landColB: number[] = [];      const landA: number[] = [];      const seaA: number[] = [];      for (let k = 0; k <= STEPS; k++) {        const q = k / STEPS;        const m = Math.pow(q, 1.2) * MAX_MIX;        landColR[k] = inkArr[0] + (accArr[0] - inkArr[0]) * m;        landColG[k] = inkArr[1] + (accArr[1] - inkArr[1]) * m;        landColB[k] = inkArr[2] + (accArr[2] - inkArr[2]) * m;        landA[k] = 0.13 + 0.87 * q;        seaA[k] = 0.03 + (isDark ? 0.11 : 0.12) * q;      }      const off = cell * 0.25;      for (let gy = gy0; gy <= gy1; gy += cell) {        for (let gx = gx0; gx <= gx1; gx += cell) {          const ccx = gx + cell / 2;          const ccy = gy + cell / 2;          const nx = (ccx - cx) / R;          const ny = -(ccy - cy) / R;          const l = nx * nx + ny * ny;          if (l > 1) continue;          const edge = smooth(1.0, 0.8, l);          if (edge <= 0.004) continue;          const vz = Math.sqrt(1 - l);          let hits = 0;          for (let sx4 = -1; sx4 <= 1; sx4 += 2) {            for (let sy4 = -1; sy4 <= 1; sy4 += 2) {              const snx = (ccx + sx4 * off - cx) / R;              const sny = -(ccy + sy4 * off - cy) / R;              const sl = snx * snx + sny * sny;              if (sl > 1) continue;              const svz = Math.sqrt(1 - sl);              const sry = cT * sny + sT * svz;              const srz = -sT * sny + cT * svz;              const swx = cP * snx - sP * srz;              const swz = sP * snx + cP * srz;              if (isLandPoint(swx, sry, swz)) hits++;            }          }          const f = hits / 4;          const litF = smooth(-0.95, 0.98, ny) * (0.45 + 0.55 * vz);          const k = Math.max(0, Math.min(STEPS, Math.round(litF * STEPS)));          const a = (seaA[k] + (landA[k] - seaA[k]) * f) * edge;          const r = Math.round(inkArr[0] + (landColR[k] - inkArr[0]) * f);          const g = Math.round(inkArr[1] + (landColG[k] - inkArr[1]) * f);          const b = Math.round(inkArr[2] + (landColB[k] - inkArr[2]) * f);          ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(3)})`;          ctx.fillRect(Math.round(gx + inset / 2), Math.round(gy + inset / 2), size, size);        }      }      ctx.globalAlpha = 1;    };    resize();    let start: number | null = null;    let prev = 0;    if (reduce) {      draw(0, 0);    } else {      const loop = (now: number) => {        if (start === null) {          start = now;          prev = now;        }        const t = (now - start) / 1000;        const dt = Math.min(0.05, (now - prev) / 1000);        prev = now;        draw(t, dt);        raf = requestAnimationFrame(loop);      };      raf = requestAnimationFrame(loop);    }    const onDown = (e: PointerEvent) => {      if (!interactive) return;      dragging = true;      vel = 0;      lastX = e.clientX;      canvas.setPointerCapture(e.pointerId);      canvas.style.cursor = 'grabbing';    };    const onMove = (e: PointerEvent) => {      if (!dragging) return;      const dx = e.clientX - lastX;      lastX = e.clientX;      const d = dx * 0.006;      phi += d;      vel = d * 12;      if (reduce) draw(0, 0);    };    const onUp = (e: PointerEvent) => {      if (!dragging) return;      dragging = false;      canvas.releasePointerCapture(e.pointerId);      canvas.style.cursor = interactive ? 'grab' : 'default';    };    if (interactive) {      canvas.style.cursor = 'grab';      canvas.addEventListener('pointerdown', onDown);      canvas.addEventListener('pointermove', onMove);      canvas.addEventListener('pointerup', onUp);      canvas.addEventListener('pointercancel', onUp);    }    const ro = new ResizeObserver(() => {      resize();      if (reduce) draw(0, 0);    });    ro.observe(canvas);    return () => {      cancelAnimationFrame(raf);      ro.disconnect();      themeObserver?.disconnect();      canvas.removeEventListener('pointerdown', onDown);      canvas.removeEventListener('pointermove', onMove);      canvas.removeEventListener('pointerup', onUp);      canvas.removeEventListener('pointercancel', onUp);    };  }, [accent, speed, resolution, gap, interactive, dark]);  return (    <canvas      ref={canvasRef}      aria-hidden      className={cn(        'h-full w-full touch-none select-none',        mask &&          '[mask-image:radial-gradient(circle_at_50%_50%,black_80%,transparent_99%)]',        className,      )}    />  );}function hexToRgb(hex: string) {  const m = hex.replace('#', '');  const v =    m.length === 3      ? m          .split('')          .map((c) => c + c)          .join('')      : m;  const n = parseInt(v, 16);  return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`;}

Usage

Give it a relative, overflow-hidden, roughly square container.

Example.tsx
import { PixelGlobe } from '@/components/ui/pixel-globe';

export default function Example() {
  return (
    <div className="relative h-96 w-full overflow-hidden">
      <PixelGlobe />
    </div>
  );
}

Examples

Drop the resolution and widen the gap for a chunkier, more retro planet.

Set gap={0} for a continuous pixel field, and swap the accent.

Props

PropTypeDefaultDescription
accentstring"#3ecf8e"The colour of the light from above, on the lit crown.
speednumber0.12Auto-rotation speed in rad/s; 0 stops it.
resolutionnumber62Tiles across the diameter, higher is finer.
gapnumber0.1Gap between tiles as a fraction of cell size; 0 is solid.
interactivebooleantrueDrag to spin, with release inertia.
darkboolean-Force a theme instead of following the .dark class.
maskbooleantrueSoften the limb with a radial mask.
classNamestring-Forwarded to the <canvas>.
On this page0%