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
| Prop | Type | Default | Description |
|---|---|---|---|
| items | ReactNode[] | One node per item, placed evenly on the ring. | |
| radius | number | 72 | Ring radius in px. |
| duration | number | 40 | Seconds for one full turn. |
| children | ReactNode | Content 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.