Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Which semantic token the dot field is painted with. */
export type DotGlobeTone = "foreground" | "primary" | "muted"

/** Which semantic tokens the markers are painted with. */
export type DotGlobePalette = "chart" | "primary" | "muted"

/** A place on the sphere. */
export interface GlobePoint {
  /** Latitude in degrees, -90 (south) to 90 (north). Clamped — 120 north is not a place. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/dot-globe.json

Prompt

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
- 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.
  - 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.

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, 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.
- Because content sits ABOVE the canvas, it also covers the drag surface. The
  documented pattern is a prop-free one: give the overlay pointer-events-none
  and put pointer-events-auto back on the buttons and links inside it. Do NOT
  make the component do that for the consumer — only they know which parts of
  their content must stay clickable and selectable, and silently disabling
  pointer events on someone's hero copy is worse than the drag not reaching it.
- 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 canvas captures the pointer 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 canvas carries touch-action: pan-y,
  so a vertical swipe still scrolls the page on touch. pointerup, pointercancel
  and lostpointercapture all end the gesture through one guarded routine
  (releasing capture re-enters it, and the guard makes the second pass a no-op).
  Turning `drag` off mid-gesture resets the drag state, 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). devicePixelRatio
  (capped at 2) is the AUTHORITY on scale; the device-pixel box is trusted only
  when it agrees to within 0.01, purely to absorb sub-pixel rounding at 1.25x
  and 1.5x. Emulated and remoted surfaces report a 1:1 device box while the page
  renders at 2x, and believing them there ships a visibly blurry canvas.
  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, unless `drag` is on,
  pointer-events-none; the probe is aria-hidden and zero-size; the component
  adds no focusable element. With drag on, the canvas becomes a pointer target
  but stays NON-FOCUSABLE, which is what keeps aria-hidden valid — the rule is
  that an aria-hidden subtree must not contain anything focusable, not that it
  must not react to a pointer. 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 / radius radians, 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 canvas, which your content sits on top of, so an overlay that should not block the gesture takes pointer-events-none and its controls take it back. 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.

On This Page