GlobePresence
High-performance 3D globe visualisation.
Live demo
01Six office locations, default theme. Drag to rotate manually β the auto-spin resumes when you release.
- San Francisco Headquarters
- London EMEA Hub
- Tokyo APAC Hub
- Sydney Australia Office
- SΓ£o Paulo LATAM Hub
- Cape Town Africa Region
Recipes
- "Where our users are right now" hero. Pass live-session data with one brand colour. Set
rotationSpeed={0.003}for a calm idle drift; users still get drag for inspection. - Three-up dashboard. Render multiple globes in a CSS grid (the "Presence sets" tab above). Each globe owns its dataset, so dots can carry different brand colours per metric.
- Static hero (no autoRotate). Set
autoRotate={false}. Pair with a CSScontain: layout paintwrapper to keep the canvas from blowing past its column. - Sidebar context globe. 240px wide, light theme, paused-on-load. Good fit for marketing pages that need world-scale signal without becoming the thing readers scroll past.
Implementation
02<script lang="ts">
import GlobePresence from '$lib/components/GlobePresence.svelte';
const dots = [
{ lat: 51.5, lng: -0.1, label: 'London' },
{ lat: 40.7, lng: -74.0, label: 'New York' },
{ lat: 35.7, lng: 139.7, label: 'Tokyo' }
];
</script>
β
<GlobePresence {dots} autoRotate />GlobePresence renders an interactive sphere on a single <canvas> with a custom 60fps loop β no Three.js scene graph required. Markers are projected from latitude/longitude onto the sphere surface and tinted with a soft glow; pointer drag rotates the globe, and autoRotate keeps it drifting when idle. Each marker can carry a `color` to brand the dot. The render loop disposes its raf handle on destroy, so navigating away leaves no zombies.
Logic explainer
03What Does It Do? (Plain English)
A spinning 3D globe drawn on a single HTML5 <canvas>. The surface is built from 800 randomly-scattered dots that fade and brighten as they rotate around the back of the sphere; on top of that, the component plots glowing markers at any latitude/longitude pairs you give it. The whole thing rotates by itself and responds to drag, but unlike most "3D globe" components on the web, it is pure 2D canvas with hand-rolled spherical projection β no Three.js, no WebGL, no GPU shaders.
Think of it as one of those office desk globes, except every continent is dissolved into a constellation of tiny stars, and the dots on the visible side glow softly while the dots on the far side ghost into the background. It is decorative, performant, and deliberately abstract β perfect for "we have customers in 47 countries" hero sections.
How It Works (Pseudo-Code)
state:
rotation = 0 // current Y-axis rotation in radians
isDragging = false
lastPointerX = 0
mouseX, mouseY = -100, -100 // for marker hover hit-testing
hoveredMarkerId = null
respectReducedMotion = false // mirrored from media query
isVisible = true // mirrored from IntersectionObserver
constants:
DOT_COUNT = 800
GLOBE_RADIUS_RATIO = 0.4 // radius = min(w,h) * 0.4
generate dots once at module load:
for i in 0..DOT_COUNT:
phi = acos(-1 + 2 * random()) // uniform sampling on sphere
theta = random() * 2Ο
push { phi, theta }
draw() (called every frame via requestAnimationFrame):
1. Match canvas pixel size to containerWidth Γ dpr / containerHeight Γ dpr
2. ctx.clearRect()
3. for each dot:
project(phi, theta, radius, rotation) β {x, y, z}
isFront = z > 0
opacity = isFront ? 0.2 + (z/radius)*0.3 : 0.05
size = isFront ? 1 + (z/radius) : 0.5
fill circle at (centerX + x, centerY + y)
4. if dark theme: paint radial atmosphere glow
5. for each marker:
phi = (90 - lat) * Ο/180
theta = (long + 180) * Ο/180
project β screen position
if z > 0 (front hemisphere):
distance test against (mouseX, mouseY) β set hoveredMarkerId
draw radial-gradient glow + core dot + label
6. if autoRotate AND !isDragging AND !respectReducedMotion:
rotation += rotationSpeed
7. if isVisible: requestAnimationFrame(draw)
events:
pointerdown: isDragging = true; lastPointerX = e.clientX
pointermove: mouseX, mouseY = local coords
if isDragging: rotation += (e.clientX - lastPointerX) * 0.01
pointerup/leave: isDragging = false
mount:
observe prefers-reduced-motion β respectReducedMotion
IntersectionObserver on canvas β isVisible
start draw()
cleanup: cancelAnimationFrame; remove listenersThe Core Concept: Spherical-to-Cartesian Projection
This component is a 90-line maths assignment dressed up as a UI component. The interesting bit is project(), which converts spherical coordinates (phi, theta) into screen-space (x, y, z):
x = R Β· sin(Ο) Β· cos(ΞΈ + rotation)
y = R Β· cos(Ο)
z = R Β· sin(Ο) Β· sin(ΞΈ + rotation)Ο (phi) is the polar angle from the north pole β 0 is the top, Ο is the bottom, Ο/2 is the equator. ΞΈ (theta) is the azimuthal angle around the Y axis β what we usually call longitude. Adding rotation to ΞΈ rotates the whole sphere around the vertical axis, which is what produces the spin.
The output is a 3D point in camera space, but we throw away z for screen positioning β the dot's (x, y) go straight to canvas coordinates. This is an orthographic projection: parallel rays cast straight at the canvas, no perspective foreshortening, no vanishing point. The globe never looks "fatter in the middle" the way a perspective-projected sphere would; it stays a clean circle from any angle.
So why keep z at all? Two reasons:
- Front/back culling.
z > 0means "on the hemisphere facing the camera";z < 0means "behind the globe". We draw front-side dots brightly and back-side dots ghosted, so the globe reads as a translucent shell rather than a flat disc. - Depth shading.
opacity = 0.2 + (z/radius) * 0.3makes dots near the centre of the visible disc brighter than dots near the silhouette. The eye reads the gradient as curvature; the globe gains apparent depth without any actual 3D rendering.
The dot distribution uses acos(-1 + 2Β·rand()) for phi instead of rand() Β· Ο β this is the trick that prevents the dots clustering at the poles. Without acos correction, uniform random phi values would over-sample the top and bottom of the sphere (because polar regions have less surface area). The acos warps the distribution so dots sit on the sphere with uniform area density β what mathematicians call sampling a uniform distribution on the 2-sphere.
For markers, the lat/long β phi/theta conversion is straightforward:
phi = (90 β lat) Β· Ο/180 // lat 90 (N pole) β phi 0 ; lat -90 (S) β phi Ο
theta = (long + 180) Β· Ο/180 // long -180 β 0 ; long +180 β 2ΟPerformance: 800 Dots Γ 60 fps on a Phone
Each frame the component:
- Loops 800 dots, runs 5 multiplications, 2 trig functions, and one canvas
fillRectper dot β ~4000 arithmetic ops + 800 fill calls. - Loops
markers.length(typically 5β20) markers with similar maths plus a radial gradient and label. - Optionally paints one radial gradient for atmosphere glow.
On a 60 Hz display this is ~50,000 arithmetic ops/sec for the dots β trivial for any browser engine. The bottleneck is the canvas itself: 800 separate arc + fill calls per frame is more state changes than ideal. In practice it costs ~2β3 ms per frame on a mid-range phone, leaving 13 ms of budget unused.
Two performance levers keep that budget healthy:
IntersectionObserverpause. When the canvas scrolls out of viewport,isVisibleflips tofalse, therequestAnimationFramechain breaks, and CPU usage drops to zero. Re-entering the viewport restartsdraw()from the next pointer movement or auto-rotation tick.- Reduced-motion freeze.
respectReducedMotionhalts the auto-rotation increment, so the canvas only redraws on user interaction. The globe is still visible, just static β which is the right behaviour for users with vestibular sensitivities.
The dots array is generated once at module load, not per frame and not per resize β Array.from({ length: DOT_COUNT }, β¦) runs exactly once and the same globeDots array drives every frame. Resizes only re-run the canvas pixel-density adjustment.
Accessibility Deep-Dive
A spinning 3D globe is the platonic ideal of "inaccessible eye candy", so the component pairs the canvas with a hidden semantic equivalent:
<div class="sr-only">
<h3>Global Presence Locations</h3>
<ul>
{#each markers as marker (marker.id)}
<li>{marker.name}: {marker.lat}, {marker.long} {marker.label ? `(${marker.label})` : ''}</li>
{/each}
</ul>
</div>Screen readers see a heading and a flat list; sighted users see the globe. The list updates whenever the markers prop changes, so AT users always have parity with the visual layer. The container itself carries role="img" and aria-label="Interactive 3D Globe showing global presence" β which is the right ARIA semantics for "decorative visualization with a textual description nearby".
For motion-sensitive users, prefers-reduced-motion: reduce halts auto-rotation. Drag interaction is preserved because dragging is user-initiated motion, which the reduced-motion media query is not designed to suppress (per the spec's intent β it targets autoplaying motion, not direct manipulation).
The component is pointer-only: there is no keyboard equivalent for "rotate the globe with arrow keys". For applications where that matters, wrap the component and add keydown handlers that mutate rotation directly (it is $state, so it accepts external writes if the component were refactored to make it bindable). The current shipping version assumes the textual list is sufficient for keyboard users.
State Flow Diagram
ββββββββββββββββββββ
β MOUNT β generate 800 dots
β (module load) β attach observers
ββββββββββ¬ββββββββββ
β
βΌ
ββββββββββββββββββββ
β AUTO-ROTATING β rotation += speed each frame
β isDragging=fal β draw() loops via rAF
ββββββββββ¬ββββββββββ
β
ββββββββββββββββββββββΌβββββββββββββββββββββ
β pointerdown β scroll out of view β reduced-motion ON
βΌ βΌ βΌ
ββββββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββ
β DRAGGING β β PAUSED β β STATIC β
β rotation += dx β β isVisible=falseβ β no auto-rotate β
β no auto-rotate β β rAF chain stopsβ β drag still OK β
ββββββββββ¬ββββββββββ ββββββββββ¬ββββββββββ ββββββββββββββββββββ
β pointerup β scroll in view
βΌ βΌ
ββββββββββββββββββββ
β AUTO-ROTATING β resumes
ββββββββββββββββββββ
hover over a marker:
distance(mouse, projected marker pos) < 10 β hoveredMarkerId = marker.id
label promoted from optional to "always show", core dot brightens to whiteProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
markers |
GlobeMarker[] |
[] |
Points to plot. Each needs id, name, lat, long; optional value, color, label. |
autoRotate |
boolean |
true |
Rotate the globe on its Y axis automatically. Disabled while dragging or under reduced-motion. |
rotationSpeed |
number |
0.005 |
Radians added to rotation per frame at 60 fps. 0.005 β one full revolution per ~21 seconds. |
interactive |
boolean |
true |
If false, drag-to-rotate is disabled. Hover hit-testing still runs for marker labels. |
theme |
'dark' | 'light' |
'dark' |
Dot/marker colours and the cyan atmosphere glow. Light theme drops the glow entirely. |
class |
string |
'' |
Extra classes on the outer container. |
The GlobeMarker shape:
interface GlobeMarker {
id: string;
name: string; // shown in tooltip when hovered
lat: number;
long: number;
value?: number; // reserved for future sizing
color?: string; // overrides theme accent for this marker
label?: string; // permanently visible (small text), in addition to hover tooltip
}Edge Cases
| Situation | Behaviour |
|---|---|
markers = [] |
Globe renders fine β just dots, no plotted points. |
| Marker on far side of globe | z < 0 skips drawing entirely. As rotation brings it forward, it fades in the moment it crosses the silhouette. |
| Two markers at the same lat/long | Both draw at the same pixel; the second overlays the first. Hover hits whichever drew last. |
| Container is square vs. rectangular | Globe radius uses min(width, height) Γ 0.4, so the sphere stays circular and centred regardless of aspect ratio. |
| High-DPI displays | canvas.width = containerWidth Γ dpr; ctx.scale(dpr, dpr) keeps dots crisp on retina. |
prefers-reduced-motion: reduce |
respectReducedMotion = true; auto-rotation stops, drag still works because the user explicitly initiated it. |
| Component scrolls offscreen | IntersectionObserver reports isIntersecting = false; rAF chain halts; CPU drops to zero. Re-entry restarts the chain. |
| SSR | Module-level dot generation runs without DOM, but draw() only fires from onMount (browser-only). No hydration mismatch β the canvas starts blank server-side. |
| Pointer leaves the canvas mid-drag | pointerleave releases the capture and clears isDragging. The rotation freezes at its current angle. |
| Rapid resize | containerWidth / containerHeight are bound via bind:clientWidth/clientHeight; the next draw() call resizes the canvas backing store. |
Dependencies
- Zero external dependencies β no Three.js, no WebGL, no animation library. The whole thing is HTML5
<canvas>2D API andMath.sin/cos. - Svelte 5.x β
$state,$props,bind:clientWidth,IntersectionObserverintegration viaonMount. cnfrom$lib/utilsβ class-merging helper, optional cosmetic only.
This is one of the few cases where building native is genuinely better than reaching for Three.js: a Three.js globe with similar dot density compiles to ~600 KB (Three core) plus the application code, versus this component's ~5 KB. The trade-off is no real lighting model and no true 3D β but for a decorative, abstract globe, those are non-features.
File Structure
src/lib/components/GlobePresence.svelte # canvas + projection maths
src/lib/components/GlobePresence.md # this file (rendered inside ComponentPageShell)
src/routes/globepresence/+page.svelte # demo page
src/lib/types.ts # GlobePresenceProps, GlobeMarker
src/lib/utils.ts # cn() class-merging helperAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
markers | GlobeMarker[] | [] | Plottable points keyed by id with lat / long / optional label / optional color / optional value. |
autoRotate | boolean | true | Continue rotating when no pointer is interacting. |
rotationSpeed | number | 0.005 | Radians per frame for the idle rotation drift. |
interactive | boolean | true | Allow pointer/touch drag to override the auto rotation. |
theme | 'light' | 'dark' | 'dark' | Selects palette and grid contrast for the canvas render. |
class | string | '' | Extra utility classes appended to the wrapper. |