Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

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

/** Which semantic tokens the bodies, their trails and the conjunction chords are painted with. */
export type OrbitsPalette = "chart" | "primary" | "muted"

/** How the field radius is fitted to a container that is not square. */
export type OrbitsFit = "contain" | "cover"

export interface OrbitRing {
  /**
   * Orbit radius as a fraction of the field radius (see `fit`). `1` puts the

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/orbits.json

Prompt

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 motionradius and period are props, so an orbit can mean something: a tier, a poll interval, a pipeline that runs backwards. With no rings the 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 angleangle = phase + rate · elapsed is recomputed from simulated time every frame rather than integrated. Dropped frames, a clamped dt and 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 color and 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 at globalAlpha 1.
  • 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 = 0 look deliberate rather than broken.

On This Page