Dot Globe
A rotating sphere of dots on one canvas — lat/lng places or a Fibonacci lattice, projected orthographically with a dimmed far hemisphere, pulsing markers and optional drag-to-spin.
Loading preview…
- Registry dependencies
- utils, canvas-surface, use-prefers-reduced-motion
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/dot-globe.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DotGlobe" component — a rotating sphere
of dots painted on one canvas, used as a hero / coverage / status backdrop.
Points come from lat/lng data or from a Fibonacci lattice. Its only dependency
is a cn() class merger (clsx + tailwind-merge). No three.js, no SVG, no
animation library.
Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
registryDependencies) — `usePrefersReducedMotion()` gates the rAF loop and
the fling. If rebuilding without it: a useSyncExternalStore over
matchMedia("(prefers-reduced-motion: reduce)") with a "change" listener and a
server snapshot of false.
- export function DotGlobe(props): props extend React.ComponentProps<"div">
(rest props spread onto the root, so ref / id / data-* pass through) plus:
- points?: GlobePoint[] — the dot field. GlobePoint = { lat: number; lng: number },
degrees. Omit it for the derived Fibonacci lattice.
- count?: number (default 900) — dots in the DERIVED lattice, clamped to
0..2000. Ignored when `points` is provided; say so in its JSDoc, it is the
one prop whose meaning depends on another.
- markers?: GlobeMarker[] — highlighted places drawn over whichever field is
in use. GlobeMarker = GlobePoint + { tone?: number } (1-based palette slot,
wrapping, default = the marker's own index). At most 64 are honoured.
- speed?: number (default 1) — multiplier on the auto-rotation, clamped to
-6..6. Negative turns the globe westward; 0 freezes the spin.
- heading?: number (default 0) — longitude at the centre of the disc at t = 0.
- tilt?: number (default 18) — degrees the north pole leans toward the viewer,
clamped to -70..70. 0 looks straight at the equator.
- drag?: boolean (default false) — pointer drag to spin, with capped inertia.
The container becomes the drag surface; the canvas stays inert.
- dotSize?: number (default 1.4) — near-hemisphere dot radius in CSS px;
marker size derives from it. Clamped to 0.3..8.
- backside?: number (default 0.3) — brightness of the far-hemisphere dots
relative to the near ones, clamped 0..1. 0 hides them entirely.
- pulse?: boolean (default true) — expanding ring on each marker that faces
the viewer.
- rim?: boolean (default false) — stroke the sphere's limb circle.
- inset?: number (default 8) — CSS px between the container edge and the sphere.
- tone?: "foreground" | "primary" | "muted" (default "muted") — the dot field's
token.
- palette?: "chart" | "primary" | "muted" (default "chart") — the markers'
tokens; "chart" cycles --chart-1..5 across them.
- children render above the canvas; className merges onto the root via cn().
- "use client": canvas, rAF, observers and pointer events.
- Clamp every numeric prop up front and treat a non-finite value as the default:
a NaN tilt blanks the canvas, count 1e6 freezes the tab, a negative inset
pushes the sphere past the clip, and a backside above 1 would make the far
side brighter than the near one.
- Destructure onPointerDown / onPointerMove / onPointerUp / onPointerCancel /
onLostPointerCapture and call each from the component's own handler (the
consumer's first), with the handlers ALWAYS attached and checking `drag`
inside. Spreading the rest props over `drag ? handler : undefined` lets a
consumer's onPointerDown silently replace the drag, and dropping them when
drag is off would swallow the consumer's handler.
- Shared primitive: canvas-surface (installed automatically via
registryDependencies). If rebuilding without it: size the canvas from a
ResizeObserver (device-pixel-content-box when supported, else the content
box; positive sizes only), run the loop only while an IntersectionObserver
reports the canvas on screen and document.visibilityState is "visible"
(start active so the first resize can start it, then act only on changes),
and re-read the theme when the class, style or data-theme attribute of
<html> changes.
Geometry
- lat/lng to a unit vector, ONCE per point at build time:
x = cos(lat) * sin(lng), y = -sin(lat), z = cos(lat) * cos(lng)
y is negated because canvas y grows downward, and +z faces the viewer. Written
in this frame, spinning the globe is a rotation of (x, z) alone.
- Spin by angle a is therefore two multiply-adds per point:
x' = x * cos(a) + z * sin(a)
z' = z * cos(a) - x * sin(a)
because x*cos + z*sin expands to cos(lat) * sin(lng + a). The loop calls no
trigonometry per point at all — one cosine and one sine per FRAME serve every
dot, which is what keeps 2000 of them under a millisecond.
- Axial tilt is a rotation about the screen's x axis, so it mixes y and z only:
yv = y * cos(t) + z' * sin(t)
zv = z' * cos(t) - y * sin(t)
The silhouette stays a circle whatever the tilt; only the poles move. Positive
tilt leans the north pole toward the viewer.
- Orthographic projection: screenX = cx + x' * R, screenY = cy + yv * R, and zv
is the depth, +1 at the nose and -1 at the antipode. No perspective divide —
a sphere seen from far away IS orthographic, and dividing by z would give a
fish-eye that reads as a bubble rather than a planet.
- Sphere radius R = max(0, min(width, height) / 2 - inset). Vectors are unit
length, so a resize needs no pass over the field: the same instant simply
re-renders at the new R.
- Fibonacci lattice (no `points`): y is stepped in EQUAL increments,
y_i = 1 - 2(i + 0.5)/n, with theta_i = i * goldenAngle, goldenAngle =
pi * (3 - sqrt(5)). Equal steps in HEIGHT carry equal area on a sphere
(Archimedes), so this is even everywhere; stepping latitude uniformly instead
produces the pole crowding a lat/lng grid has.
- heading is applied as spin = -heading * pi / 180, because a point sits at the
nose when its longitude plus the spin is zero.
Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that is
aria-hidden, ALWAYS pointer-events-none, absolute inset-0 and size-full — the
size-full matters, an absolutely positioned replaced element with inset-0
alone renders at its intrinsic 300x150 — (b) a zero-size aria-hidden span
used as the token probe, and (c) a "relative z-10" wrapper for children, so
content always sits above the field. The component paints NO background of
its own and gives itself NO height: the surface and the box belong to the
consumer.
- The drag surface is the CONTAINER, not the canvas: content sits above the
canvas and would swallow every press aimed at it, so a canvas-level listener
would only ever hear the uncovered margin. A press that starts on button, a,
input, select, textarea, [role=button] or [data-no-drag] is ignored, so
controls in the content keep working. A mouse drag that starts on selectable
copy also selects it; an overlay that should not can take
pointer-events-none. Do NOT make the component do that for the consumer —
only they know which parts of their content must stay clickable and
selectable.
- Validation is a REFUSAL for the impossible and a REPAIR for the merely out of
range. A place whose lat or lng is not finite is dropped: one NaN turns every
later Math call into NaN and blanks the whole frame, which reads as "this
component is broken" instead of "that one row was wrong". Latitude is CLAMPED
to -90..90 (100 north is not a place) but longitude is WRAPPED into
[-180, 180) (an angle is periodic, so 400 east is a legitimate 40). An empty
`points`, or one whose every row failed, falls back to the lattice — a blank
canvas reads as a crash.
- Draw order is a painter's algorithm with no sorting: one projection pass fills
three pooled Float64Arrays (screen x, screen y, depth), then the far-side dots
are drawn, then the near-side ones, then the markers. Two passes over the same
arrays cost far less than sorting 2000 entries every frame.
- Depth cues, and there are three, because an orthographic sphere is otherwise a
flat disc of dots:
(1) far-side dots are dimmed by `backside` and drawn at 0.72x radius;
(2) far-side brightness also falls with (1 + z), so the far side fades toward
the antipode instead of sitting at one flat grey;
(3) limb darkening on the near side — alpha scales as 0.55 + 0.45z and radius
as 0.82 + 0.18z, so dots dim and shrink toward the edge of the disc.
- Markers: a pulse ring, a halo at 3.2x and an opaque core, in that order, so
the ring is never drawn over the disc it comes from. Ring radius grows to
3.4x over one 2.8s cycle with alpha (1 - u)^2, and each marker's phase is
offset by the golden-ratio conjugate times its index — a low-discrepancy
stride, so a row of pins never breathes in unison, and deterministic, so the
still frame is reproducible. Only markers facing the viewer pulse.
- A marker on the far side is dimmed but NEVER deleted: its visibility is
max(backside, 0.16). backside=0 is a legitimate look (an opaque ball), and a
marker silently vanishing from it would be a lie about the data.
- Drag (opt-in): pointerdown on the container captures the pointer on the
container and writes the whole gesture state — id, last x, last timestamp,
zeroed velocity — in one synchronous shot; a frame that reads a half-written
drag is how a globe ends up leaping on the first move. pointermove adds
dx / R radians to the spin: dx px across the middle of a disc of radius R is
exactly that many radians of longitude, so there is no gain constant and the
surface goes where the pointer puts it. Everything is a REF write, never
state — a trackpad emits far more than 60 events/s and children must not
re-render for a decoration. Only the primary button starts a gesture, and a
second pointer is IGNORED while one is in progress rather than silently
rewriting its state mid-flight.
- Fling: velocity is (dx / R) / dt from the EVENT's own timestamp (never a clock
read), smoothed 35% per sample so one stuttering frame cannot decide the whole
fling, and capped at 6 rad/s. It decays as exp(-dt / 0.42s) in BOTH states —
after the release so it dies out, and while the pointer is still down so that
holding the globe stationary for a second cannot bank a fling from the flick
that got it there. Auto-rotation is suspended while the pointer holds the
globe and resumes on release, added on top of the decaying inertia.
- Only horizontal drag is honoured, and the container carries touch-action:
pan-y, so a vertical swipe still scrolls the page on touch. The container
shows cursor-grab, and a data-dragging attribute set on it for the length of
a gesture switches it to cursor-grabbing (a cursor set on the
pointer-events-none canvas is never the one shown). pointerup, pointercancel
and lostpointercapture all end the gesture through one guarded routine that
clears the attribute and releases the container's capture (releasing capture
re-enters it, and the guard makes the second pass a no-op). Turning `drag`
off mid-gesture resets the drag state and the attribute, or the globe would
stay pinned to a pointer that no longer reports.
- Keyboard map: NONE, deliberately. The component adds no tab stop and no
shortcut, because it exposes nothing a keyboard user could need — dragging
only reaches a face the auto-rotation reaches on its own within one
revolution, and no marker is a target. If you need a focusable, operable
globe, that is a control, not a background: give it a role, a label, arrow-key
rotation and a visible focus ring, and drop the aria-hidden below.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
padding would offset the box); its first callback is the initial sizing. Try
observe(canvas, {box: "device-pixel-content-box"}) inside a try/catch —
browsers that do not know that box throw a WebIDL TypeError from observe()
rather than ignoring it — and fall back to observe(canvas). Take the MAX of
devicePixelRatio and deviceBoxWidth / cssWidth, capped at 2: the two sources
disagree in both directions. Emulated and remoted surfaces report a 1:1
device box while the page renders at 2x (believing the box there ships a
visibly blurry canvas), and some real 2x windows report devicePixelRatio 1
with an accurate device box. When they agree to within 0.01 the exact device
box is used verbatim, absorbing sub-pixel rounding at 1.25x and 1.5x.
Re-apply ctx.setTransform after every resize (writing canvas.width resets the
context) and derive the scale from the actual backing size.
- Density: the DERIVED lattice is thinned to one dot per ~105 px² of projected
disc by a STRIDE (draw every k-th point), never by rebuilding at a smaller n:
every point of a Fibonacci lattice moves when n changes, so rebuilding would
reshuffle the whole globe on every resize, while every k-th point of a lattice
is still a lattice (uniform y steps, golden angle times k). Supplied points
are never thinned — that data is the content, not decoration.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas is
on screen, document.visibilityState is "visible", motion is allowed, and there
is something to animate (speed non-zero, or drag, or a pulsing marker). dt is
clamped to 1/30s so a backgrounded tab cannot teleport the globe on resume,
and the time base resets when the loop restarts. A background that burns a
core in a hidden tab is a defect.
- Long-run hygiene: the spin is wrapped by 2*pi and the pulse clock by one
period every frame, so neither accumulates into the range where a double loses
precision, however long the page stays open.
- Both the spin and the pulse clock live in REFS, not in variables scoped to the
effect: toggling a prop (the palette, the pulse, the rim) rebuilds the field,
and a fresh 0 there would snap a globe that is mid-revolution back to its
starting face. `heading` is re-applied only when it actually changes, tracked
by a ref holding the last applied value.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server
snapshot false, so it is hydration-safe) and keep it in the effect deps. Under
reduce the loop never starts and exactly one frame is painted: the requested
heading, both hemispheres, every marker with its pulse ring already spread
across the cycle. Drag still works there and repaints synchronously through a
ref-held draw function — dragging is motion the USER is making — but the
inertia is dropped on release, because that is motion the component would be
adding after the user stopped.
- Cleanup on unmount and on every dependency change: cancelAnimationFrame, the
ResizeObserver, the IntersectionObserver, the MutationObserver, the
visibilitychange listener, and the ref-held draw function.
- Array prop hygiene: `points` and `markers` are new identities on every render
when written inline, so serialise them (JSON.stringify) and key the useMemo
that builds the field on those strings. The round trip also normalises NaN and
Infinity to null, which validation drops in exactly the same way.
Rendering & styling
- Semantic tokens only, zero colour literals: the dot field is var(--foreground)
/ var(--primary) / var(--muted-foreground), markers are var(--chart-1..5) or a
single token, and the limb is var(--border). A zero-size probe span INSIDE the
container carries each token in turn as an inline `color`; the computed value
is read back and assigned to fillStyle / strokeStyle verbatim, with all alpha
on globalAlpha. Never hand-parse a colour — passing the computed string
through means oklch(), color-mix() and a rebranded palette all work, a panel
that overrides the tokens locally is honoured because the probe lives inside
it, and an undefined variable degrades to the inherited text colour instead of
throwing. Read the tokens on mount, when the tab becomes visible, when the
globe scrolls back on screen, and on a theme flip (MutationObserver on
documentElement watching class / style / data-theme) — never per frame.
- var(--border) already carries its own alpha in the dark theme, so stroke the
limb at globalAlpha 1; dimming that token a second time erases the circle.
- Merge the consumer className via cn() on the root; the canvas and the probe
keep their own classes.
- ARIA contract: the canvas is aria-hidden and always pointer-events-none; the
probe is aria-hidden and zero-size; the component adds no focusable element.
With drag on, the CONTAINER takes the pointer and the canvas stays inert and
NON-FOCUSABLE, which is what keeps aria-hidden valid — an aria-hidden
subtree must not contain anything focusable. The dots carry no meaning a
screen reader should hear: they are a picture of a sphere, and the places
they encode belong in text nearby. Children stay fully interactive,
selectable and above the field, and nothing here traps scroll or focus.
- Cost, honestly: at the defaults a 1440x600 hero draws ~900 dots — one arc fill
each, roughly half of them dimmed — plus three fills and a stroke per visible
marker. Cost is linear in the effective dot count; the area stride and the
2000-dot ceiling are the two safety valves, and the loop is dead while the
section is off screen or the tab is hidden.
Customization levers
- Meaning: `markers` is the point of the component — feed it the coordinates you
already have (regions, edge nodes, offices, customers) and the backdrop starts
saying something. `points` replaces the whole field, so a coastline or a
point-per-datacentre dataset turns the lattice into your map; keep it under a
couple of thousand rows.
- Framing: `tilt` and `heading` decide what the first (and, under reduced
motion, only) frame shows — tilt 0 is equator-on, 60+ looks down on the pole,
and heading picks the meridian at the centre. `inset` controls how much of the
box the sphere claims; make it negative-ish by shrinking the box instead if
you want the globe to bleed off the bottom edge.
- Depth: `backside` is the single biggest look knob — 0 is an opaque ball, 0.3
is a solid globe you can sense the far side of, 1 is a glass wireframe. The
limb-darkening ramps (0.55 + 0.45z, 0.82 + 0.18z) and BACK_SIZE are where you
tune how strongly the sphere reads as round.
- Density and cost: `count` is the request, DOT_AREA (px² per dot) is the area
stride that keeps a small card from turning to mud, and MAX_POINTS is the
ceiling. Lower MAX_DPR to 1 to halve fill cost on retina for very large heroes.
- Motion: `speed` scales and reverses the spin (BASE_RATE is one revolution per
minute at speed 1), and 0 is a legitimate look — a frozen globe reads as a
diagram. PULSE_PERIOD / PULSE_GROWTH / PULSE_ALPHA shape the marker ring;
pulse={false} removes it entirely.
- Feel of the drag: there is no gain constant by design, but FLING_DECAY is how
long it coasts, MAX_FLING the ceiling on a flick, and FLING_SMOOTH how much a
single jittery sample counts. Add vertical drag to `tilt` if you want a full
trackball — clamp it and expect to give up touch-action: pan-y for it.
- Palette: extend the token records with any token — var(--destructive) marks
incident regions, var(--primary-foreground) is the right ink on an inverted
panel. Per-marker `tone` pins a place to a colour so a region keeps its slot
across pages.Concepts
- Orthographic sphere — points are unit vectors projected with no perspective divide, because a planet seen from far away really is orthographic; dividing by depth would give a fish-eye bubble. Roundness comes from three cues instead: the far side is dimmer and smaller, its brightness falls toward the antipode, and near-side dots dim and shrink toward the limb.
- Trig-free spin — writing a place as
(cos lat · sin lng, -sin lat, cos lat · cos lng)makes rotation a 2D turn of x and z, so each dot costs two multiply-adds per frame and the whole field shares one cosine and one sine. That is what lets 2000 dots run without a worker. - Fibonacci lattice — the derived field steps height uniformly and offsets each point by the golden angle, because equal heights carry equal area on a sphere. A plain lat/lng grid crowds the poles; this does not, at any count.
- Stride thinning — a small card gets fewer dots by drawing every k-th point rather than rebuilding the lattice at a smaller n. Every point of a Fibonacci lattice moves when n changes, so rebuilding would reshuffle the globe on every resize; a stride keeps it still. Supplied points are exempt — that data is the content.
- Markers keep a floor — a place on the far hemisphere dims but never disappears, even at
backside=0, where the dot field is hidden outright. The pulse rings are offset by a golden-ratio stride of their index, so pins never breathe in unison and the still frame is reproducible. - Drag with capped inertia — the pointer moves the surface by
dx / radiusradians, which is the exact arc it dragged, through ref writes that never re-render a child. The fling is smoothed, capped, and decays while the pointer is still down as well as after release, so holding the globe still cannot bank a flick. The drag surface is the container, so the gesture reaches through your content; presses that start on buttons, links, form fields,[role=button]or[data-no-drag]stay with them. Under reduced motion the drag survives and repaints synchronously; only the inertia is dropped, because that is motion the component would add after the user stopped.
Orbits
Concentric orbital paths with bodies at their own periods — fading arc trails, a chord whenever two orbits line up, and radii and periods as props so the field can encode real values.
Metaball Blobs
A gooey organic background — token-tinted blobs drift on seeded orbits and fuse into one body through an SVG alpha-threshold filter, optionally leaning toward the pointer.