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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/orbits.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Orbits" component — concentric orbital
paths with bodies riding them at their own periods, painted on one canvas and
used as a hero / status / section backdrop. Radii and periods are props, so the
field can encode real values (poll intervals, tiers, cadences) instead of being
decoration only. Its only dependency is a cn() class merger (clsx +
tailwind-merge). No animation library, no SVG.
Contract
- export function Orbits(props): props extend React.ComponentProps<"div">
(rest props spread onto the root, so ref / id / data-* pass through) plus:
- rings?: OrbitRing[] — the orbits to draw. Omit it for the derived set below.
OrbitRing = {
radius: number // fraction of the field radius; 1 = field edge, clamped 0.02..1.5
period: number // seconds per revolution; positive = clockwise,
// negative = retrograde, 0 = pinned
bodies?: number // default 1, clamped 1..12, spread evenly around the ring
phase?: number // degrees clockwise from 12 o'clock; default index * 137.5
size?: number // body radius in CSS px, default 4, clamped 0.5..24
tone?: number // 1-based palette slot, wraps; default = the ring's index
}
- count?: number (default 4, clamped 1..8) — how many orbits the DERIVED set
has. It is ignored when `rings` is provided; say so in its JSDoc, it is the
one prop whose meaning depends on another.
- speed?: number (default 1) — multiplier on simulated time. 0 freezes the
field on its first frame and the rAF loop never starts at all.
- palette?: "chart" | "primary" | "muted" (default "chart") — which semantic
tokens the bodies, trails and chords are painted with. "chart" cycles
--chart-1..5 across the orbits; the other two are single-token.
- trail?: number (default 0.18) — trail length as a fraction of ONE
REVOLUTION, clamped 0..1. 0 removes it.
- paths?: boolean (default true) — stroke the orbit circles themselves.
- alignments?: boolean (default true) — chord between two bodies on different
orbits while they are within ~9 degrees of each other.
- hub?: boolean (default true) — the small disc the system turns around.
- inset?: number (default 12) — CSS px between the container edge and the
outermost orbit.
- fit?: "contain" | "cover" (default "contain") — "contain" fits the field
radius to the container's shorter side, "cover" to the longer one, so a
banner-shaped box gets orbits running off the top and bottom instead of a
small circle floating in the middle.
- children render above the canvas; className merges onto the root via cn().
- "use client": canvas, rAF and observers.
- Clamp every numeric prop up front and treat a non-finite value as the default:
a NaN speed freezes time at NaN and blanks the canvas, a negative inset pushes
the outermost orbit past the clip, and a trail longer than one turn wraps over
its own head.
Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that is
aria-hidden, 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 and the canvas can never intercept a click. The
component paints NO background of its own and gives itself NO height: the
surface and the box belong to the consumer (a height class, or children with
padding).
- Geometry: fieldRadius = max(0, (fit === "cover" ? max(w,h) : min(w,h)) / 2 -
inset); a body sits at radius * fieldRadius from the container centre. Because
radii are FRACTIONS, a resize needs no rescale pass — the same instant simply
re-renders at the new size, unlike a field that stores pixel positions.
- Angle is a CLOSED FORM of simulated time: angle = phase + (TAU / period) *
elapsed, where elapsed accumulates dt * speed with dt clamped to 1/30s. Never
integrate the angle incrementally: dropped frames and a backgrounded tab would
slowly put the bodies out of phase with the periods they are supposed to
encode, which is the one thing this component promises. `phase` is converted
from "degrees clockwise from 12 o'clock" to canvas radians as
(phase - 90) * PI / 180. Nothing ever reads a clock at render time, so SSR and
hydration agree and screenshots are reproducible.
- Keep `elapsed` in a ref, not in a variable scoped to the effect: toggling a
prop (the chords, the palette, the trail length) rebuilds the field, and a
fresh 0 there would teleport every body back to its starting angle mid-flight.
- Derived ring set (no `rings` prop): radii spread evenly from 0.34 to 1.0 and
period = 24 * radius^1.5 — Kepler's third law. Inner bodies visibly outrun
outer ones and the period ratios stay irrational enough that conjunctions
recur without the field settling into lockstep. Phases step by the golden
angle (137.5 deg) so the first painted frame is never a straight line.
- Ring validation is a REFUSAL, not a repair: a ring whose radius or period is
not finite, or whose radius is <= 0, is dropped. One NaN would turn every
later Math call into NaN and blank the entire frame, which reads as "the
component is broken" instead of "that one ring was wrong". Everything else is
clamped. At most 8 rings and 12 bodies per ring are honoured, so a typo cannot
ask for ten thousand discs.
- Pooling: resolving the rings produces a flat body list (radius fraction, rate,
phase, size, tone, ring index) plus the distinct radii to stroke, built once
per prop change. The loop allocates NOTHING per frame: three Float64Arrays
(angle, x, y) plus one for the conjunction glow are allocated with the field
and refilled each frame. Per-frame allocation is the failure mode of a
full-screen background.
- Trail: a constant ARC, not a constant length — every body shows the same
amount of orbital history and an outer tail is physically longer because that
body really has travelled further. It is drawn as 12 stroked arc slices whose
alpha ramps as (i/12)^2 and whose width ramps 0.25x..1x of the body radius.
Keep the DEFAULT butt line cap: a round cap overhangs every joint by half the
line width and composites two slices' alphas on top of each other, beading the
tail every few pixels — and the head needs no cap because the body's own disc
is drawn over it. A retrograde body needs the arc's counter-clockwise flag
flipped or each slice takes the long way round the circle. A PINNED body
(period 0) draws no trail at all: a trail is a claim about where the body has
been.
- Conjunctions: one O(n^2) sweep over body pairs — n is at most 96 and typically
under 10, so the pair test is cheaper than one extra arc fill. Pairs on the
SAME orbit are skipped: they hold a fixed separation for ever and calling that
a conjunction would be meaningless. Wrap the angle difference into [-PI, PI]
(d - TAU * round(d / TAU)) so 359 degrees apart reads as 1, and when the gap is
under ~0.16 rad stroke a chord between the two bodies with
alpha = 0.45 * closeness^2, closeness = 1 - gap / 0.16. Both ends also
brighten (the glow array scales their halo alpha). Born and buried at zero
opacity, so a chord never pops in.
- Draw order: orbit paths, hub, trails, chords, then bodies last so every trail
and chord terminates under a disc. Each body is a halo fill at 3.4x radius,
alpha 0.14, plus an opaque core. The hub is the one element sized off the
field rather than in fixed px (2.5% of the field radius, clamped to 2.5..6),
so it stays a dot in a card and a disc in a full-bleed hero.
- Colour: 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 field scrolls back on
screen, and on a theme flip — never per frame.
- 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/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.
- 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 > 0 AND at least one body is not pinned). The
time base resets when the loop restarts. A background that burns a core in a
hidden tab is a defect.
- 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: a complete
system with the phases already spread apart, trails showing each heading, and
theme ink. A frozen field (speed 0) paints the same frame, and so does a
resize while the loop is paused.
- Cleanup on unmount and on every dependency change: cancelAnimationFrame, the
ResizeObserver, the IntersectionObserver, the MutationObserver and the
visibilitychange listener.
- Array prop hygiene: an inline `rings` literal is a new identity on every
render, so serialise it (JSON.stringify) and key the useMemo that builds the
field on that string. The round trip also normalises NaN and Infinity to null,
which the validation drops in exactly the same way.
Rendering & styling
- Semantic tokens only, zero colour literals: bodies and trails come from
var(--chart-1..5) / var(--primary) / var(--muted-foreground), the orbit paths
from var(--border) and the hub from var(--muted-foreground). Light, dark and
any rebranded palette come for free.
- var(--border) already carries its own alpha in the dark theme, so stroke the
paths at globalAlpha 1 — dimming that token a second time erases the ring.
- Merge the consumer className via cn() on the root; the canvas and the probe
keep their own classes.
- Accessibility contract: the canvas is aria-hidden and pointer-events-none, the
probe is aria-hidden and zero-size, and the component adds no focusable
element and no keyboard surface of its own. It is decoration: it sits behind
content, never traps scroll or focus, and the bodies carry no meaning a screen
reader should hear — if a body must be clickable or labelled, that is a
content-layer component, not this one. Children stay fully interactive,
selectable and above the field.
Customization levers
- Meaning: `rings` is the whole point — map radius to a tier and period to a
real interval and the background becomes a readable diagram of the system it
sits behind. Keep the ratios honest (a 60s job really should take 3x as long
as a 20s one) and the motion does the explaining for you.
- Density and cost: bodies are the cost, and it is linear — per body, per frame,
2 arc fills plus `trail * 12` strokes. 25 bodies at the defaults is 50 fills,
300 strokes and 300 pair tests, which is nothing; the ceilings (8 rings, 12
bodies each) exist so a typo cannot ask for more. trail=0 removes the strokes
entirely, and lowering MAX_DPR to 1 halves fill cost on retina for very large
heroes.
- Tempo: `speed` scales everything at once and 0 is a legitimate look (a frozen
system reads as a diagram). DERIVED_PERIOD (24s at radius 1) sets the derived
set's pace, and KEPLER_EXPONENT is how dramatically inner orbits outrun outer
ones — drop it to 0 for a rigid wheel where every body keeps formation.
- Trail character: `trail` is the arc fraction, TRAIL_SEGMENTS the smoothness
and TRAIL_ALPHA the weight. The (i/12)^2 alpha ramp is what makes the tail
dissolve rather than end; make it linear for a solid streak.
- Conjunctions: ALIGN_ARC is how forgiving "aligned" is (0.16 rad ~= 9 degrees;
widen it and chords stay lit for longer), ALIGN_ALPHA the loudness. Test the
wrapped difference against PI instead to highlight OPPOSITIONS, or hook the
same closeness value into a callback if the alignment means something in your
app.
- Palette: extend the palette record with any token — var(--destructive) for an
incident backdrop, var(--primary-foreground) for an inverted panel. `tone` per
ring decides which slot each orbit takes, so a tier can keep its colour across
pages.
- Framing: `fit` and `inset` decide how the system meets the box, and radii
above 1 deliberately push an orbit outside it. `paths={false}` leaves only the
bodies and their trails, which is the restrained version for text-heavy pages;
`hub={false}` frees the centre for a headline.Concepts
- Period-encoded motion —
radiusandperiodare props, so an orbit can mean something: a tier, a poll interval, a pipeline that runs backwards. With noringsthe derived set falls back to Kepler's third law,period = 24 · radius^1.5, which is what makes an inner body visibly lap an outer one instead of the whole field turning like a wheel. - Closed-form angle —
angle = phase + rate · elapsedis recomputed from simulated time every frame rather than integrated. Dropped frames, a clampeddtand a backgrounded tab therefore cost time, never phase: the bodies stay in step with the intervals they encode, and because radii are fractions of the field radius a resize needs no rescale pass at all. - Constant-arc trail — the tail spans a fixed fraction of a revolution, not a fixed number of pixels, so every body shows the same amount of history while an outer tail is physically longer. Twelve stroked slices with a squared alpha ramp make it dissolve instead of ending, and a pinned body draws none, because a trail is a claim about where the body has been.
- Conjunction chord — one pair sweep per frame joins two bodies on different orbits while their wrapped angular gap is under ~9 degrees, at
alpha = 0.45 · closeness², with both ends brightening. Born and buried at zero opacity, so an alignment fades in rather than popping; same-orbit pairs are skipped because their separation never changes. - Token probe — a zero-size span inside the container takes each token as an inline
colorand hands the computed string to the canvas. Nothing is hand-parsed, a locally themed panel is honoured, and an undefined variable degrades to inherited text colour.var(--border)already carries alpha in the dark theme, so the orbit paths are stroked atglobalAlpha1. - Paused, not blank — the loop runs only while the canvas is on screen, the tab is visible, motion is allowed and something is actually moving. When any of that is false the component still paints one composed frame — spread phases, trails, theme ink — so reduced motion and
speed = 0look deliberate rather than broken.
Galaxy
A spiral galaxy on one canvas — stars threaded onto logarithmic arms, a disc that rotates differentially, and a bright core, all painted in your theme tokens.
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.