Skip to content
goodtabs UI
BrowseBrowse components

Orbit

Items on a slowly turning ring, sized by the chord between neighbours.

npx shadcn@latest add https://ui.goodtabs.co/r/orbit.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/orbit

stack

Six tool icons orbiting a centre label.

Showing the working

  • n = 8, r = 72
  • chord = 2r × sin(180° / n)
  • chord = 55.1px
  • item = min(0.8 × chord, 40) = 40px

Eight items with the chord and item size worked out.

Usage

import { Orbit } from "@/components/ui/orbit"

<Orbit items={[<Box key="b" />, <Globe key="g" />]}>stack</Orbit>

Props

PropTypeDefaultDescription
itemsReactNode[]One node per item, placed evenly on the ring.
radiusnumber72Ring radius in px.
durationnumber40Seconds for one full turn.
childrenReactNodeContent in the centre.

Source

orbit/orbit.tsx
"use client";

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

type OrbitProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** One node per item, placed evenly on the ring. */
  items: React.ReactNode[];
  /** Ring radius in px. */
  radius?: number;
  /** Seconds for one full turn. */
  duration?: number;
  /** Content in the centre. */
  children?: React.ReactNode;
};

/**
 * Items on a ring. The gap between neighbour centres is the chord 2r × sin(180deg / n), and the
 * item size is capped at 80 percent of it so neighbours never touch. The ring turns slowly,
 * items counter-rotate to stay upright, and it stops under reduced motion and off screen.
 */
export function Orbit({ items, radius = 72, duration = 40, className, children, ...props }: OrbitProps) {
  const ringRef = React.useRef<HTMLDivElement>(null);
  const n = items.length;
  const chord = 2 * radius * Math.sin(Math.PI / n);
  const itemSize = Math.min(Math.floor(chord * 0.8), 40);
  const box = radius * 2 + itemSize;

  React.useEffect(() => {
    const ring = ringRef.current;
    if (!ring || typeof ring.animate !== "function") return;
    const media = window.matchMedia("(prefers-reduced-motion: reduce)");
    const spin = ring.animate([{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }], {
      duration: duration * 1000,
      iterations: Infinity,
    });
    const keep = Array.from(ring.children).map((child) =>
      child.animate([{ rotate: "0deg" }, { rotate: "-360deg" }], { duration: duration * 1000, iterations: Infinity }),
    );
    const all = [spin, ...keep];
    let visible = true;
    const sync = () => {
      for (const a of all) {
        if (visible && !media.matches) a.play();
        else a.pause();
      }
    };
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      sync();
    });
    io.observe(ring);
    media.addEventListener("change", sync);
    sync();
    return () => {
      io.disconnect();
      media.removeEventListener("change", sync);
      for (const a of all) a.cancel();
    };
  }, [duration, n]);

  return (
    <div
      className={cn("relative grid shrink-0 place-items-center", className)}
      style={{ width: box, height: box }}
      {...props}
    >
      <div
        aria-hidden="true"
        className="absolute rounded-full border border-dashed"
        style={{ width: radius * 2, height: radius * 2 }}
      />
      <div ref={ringRef} className="absolute inset-0">
        {items.map((item, i) => (
          <div
            key={i}
            style={
              {
                "--i": i,
                "--n": n,
                "--r": `${radius}px`,
                "--a": "calc(-90deg + var(--i) * 360deg / var(--n))",
                width: itemSize,
                height: itemSize,
              } as React.CSSProperties
            }
            className="absolute top-1/2 left-1/2 grid translate-x-[calc(-50%+cos(var(--a))*var(--r))] translate-y-[calc(-50%+sin(var(--a))*var(--r))] place-items-center rounded-full border bg-background text-foreground"
          >
            {item}
          </div>
        ))}
      </div>
      <div className="relative">{children}</div>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/orbit/prompt.md, or the Copy AI prompt button above.

When to use

  • Showing integrations or a stack around a central idea
  • A calm decorative accent in a hero or feature section

When not to use

  • Content people must read or click, the items are decorative
  • More than about twelve items, they get too small

Agent notes

  • Client component using the Web Animations API, no library
  • The still state is the unrotated ring. It stops under reduced motion and when scrolled off screen
  • Items counter-rotate so icons stay upright. Give them aria-hidden icons or labels as needed
  • Item size is capped at 40px and at 80 percent of the chord

The maths

  • Item i of n sits at θ = -90deg + i × 360deg / n, at x = cos θ × r and y = sin θ × r.
  • Neighbour centres are the chord 2r × sin(180deg / n) apart: 2 × 72 × sin(22.5deg) = 55.1px for n = 8.
  • Item size = min(0.8 × chord, 40), so neighbours keep a gap.