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.
'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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
accent | string | "#3ecf8e" | The colour of the light from above, on the lit crown. |
speed | number | 0.12 | Auto-rotation speed in rad/s; 0 stops it. |
resolution | number | 62 | Tiles across the diameter, higher is finer. |
gap | number | 0.1 | Gap between tiles as a fraction of cell size; 0 is solid. |
interactive | boolean | true | Drag to spin, with release inertia. |
dark | boolean | - | Force a theme instead of following the .dark class. |
mask | boolean | true | Soften the limb with a radial mask. |
className | string | - | Forwarded to the <canvas>. |