Orbit
Icons, logos or avatars that circle a center on rings and stay upright while they turn.
One of 6 components in Beams, particles and motion
Details
What Orbit brings along, read from the registry and the source.
- Category
- Beams, particles and motion
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/orbit.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The OrbitProps interface from components/ui/orbit.tsx, with the comments from the code.
ringsrequiredOrbitRing[]centeroptionalReact.ReactNodeWhat sits in the middle, e.g. your app mark
sizeoptionalnumberLargest size in px, the orbit shrinks with its container below that. Default 520
showRingsoptionalbooleanDraw the rings as hairlines, default true
dashedoptionalbooleanDashed rings instead of solid
scrollTurnoptionalnumberDegrees the rings turn on top of their run over a full scroll pass, items stay upright. Default 0 (off), 60 reads well.
labeloptionalstringRead out to screen readers, e.g. "Connects with 12 tools". Without it the orbit is decorative.
Accessibility
Read from the source of the component.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Moves with the scroll, its progress follows the position on the page.
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 125 lines
'use client';
import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion, round } from '@/lib/kit';
export interface OrbitRing {
/** Icons, logos or avatars, spread evenly around the ring */
items: React.ReactNode[];
/** Ring radius in % of the orbit size, 0 to 50 */
radius: number;
/** Seconds for one full turn, default 40 */
duration?: number;
/** Turn counterclockwise */
reverse?: boolean;
/** Start angle of the first item in degrees, default -90 (top) */
offset?: number;
}
export interface OrbitProps {
rings: OrbitRing[];
/** What sits in the middle, e.g. your app mark */
center?: React.ReactNode;
/** Largest size in px, the orbit shrinks with its container below that. Default 520 */
size?: number;
/** Draw the rings as hairlines, default true */
showRings?: boolean;
/** Dashed rings instead of solid */
dashed?: boolean;
/** Degrees the rings turn on top of their run over a full scroll pass, items stay upright. Default 0 (off), 60 reads well. */
scrollTurn?: number;
/** Read out to screen readers, e.g. "Connects with 12 tools". Without it the orbit is decorative. */
label?: string;
className?: string;
}
const CSS = `
@keyframes kitfx-orbit-spin { to { transform: rotate(360deg); } }
.kitfx-orbit * { animation-play-state: var(--kitfx-play, running); }
.kitfx-orbit-turn { rotate: calc((var(--kitfx-p, 0.5) - 0.5) * var(--kitfx-turn)); transition: rotate .3s linear; }
.kitfx-orbit-upright { rotate: calc((var(--kitfx-p, 0.5) - 0.5) * var(--kitfx-turn) * -1); transition: rotate .3s linear; }
@media (prefers-reduced-motion: reduce) { .kitfx-orbit * { animation: none !important; } .kitfx-orbit-turn, .kitfx-orbit-upright { rotate: none; } }
`;
/**
* Items circling a center on one or more rings. Items stay upright while
* their ring turns. Each item wrapper carries data-orbit-item, e.g. to hide
* the items on phones with max-sm:[&_[data-orbit-item]]:hidden. Square, responsive up to `size`. Pauses out of view and
* stands still with reduced motion.
*/
export function Orbit({ rings, center, size = 520, showRings = true, dashed, scrollTurn = 0, label, className }: OrbitProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === 'undefined') return;
const io = new IntersectionObserver(([e]) => el.style.setProperty('--kitfx-play', e.isIntersecting ? 'running' : 'paused'));
io.observe(el);
return () => io.disconnect();
}, []);
// Scroll pass: 0 when the orbit enters at the bottom, 1 when it leaves at the top
useEffect(() => {
const el = ref.current;
if (!el || !scrollTurn || prefersReducedMotion()) return;
let frame = 0;
const update = () => {
frame = 0;
const r = el.getBoundingClientRect();
el.style.setProperty('--kitfx-p', Math.min(1, Math.max(0, (window.innerHeight - r.top) / (r.height + window.innerHeight))).toFixed(4));
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
};
}, [scrollTurn]);
return (
<div
ref={ref}
{...(label ? { role: 'img', 'aria-label': label } : { 'aria-hidden': true })}
className={cn('kitfx-orbit relative mx-auto aspect-square w-full', className)}
style={{ maxWidth: size, ...(scrollTurn ? { ['--kitfx-turn' as string]: `${scrollTurn}deg` } : {}) }}
>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
{showRings &&
rings.map((r, i) => (
<div
key={`r${i}`}
className={cn('absolute rounded-full border', dashed ? 'border-dashed border-[color-mix(in_oklch,var(--foreground)_16%,transparent)]' : 'border-[color-mix(in_oklch,var(--foreground)_11%,transparent)]')}
style={{ left: `${50 - r.radius}%`, top: `${50 - r.radius}%`, width: `${r.radius * 2}%`, height: `${r.radius * 2}%` }}
/>
))}
{rings.map((r, i) => {
const dur = r.duration ?? 40;
const dir = r.reverse ? 'reverse' : 'normal';
const counter = r.reverse ? 'normal' : 'reverse';
return (
<div key={`o${i}`} className={cn('absolute inset-0', scrollTurn && 'kitfx-orbit-turn')} style={{ animation: `kitfx-orbit-spin ${dur}s linear infinite ${dir}`, ...(r.reverse && scrollTurn ? { ['--kitfx-turn' as string]: `${-scrollTurn}deg` } : {}) }}>
{r.items.map((item, k) => {
const a = round((r.offset ?? -90) + 90 + (360 / r.items.length) * k, 2);
return (
<div key={k} data-orbit-item="" className="absolute inset-0" style={{ transform: `rotate(${a}deg)` }}>
<div className="absolute left-1/2" style={{ top: `${50 - r.radius}%`, transform: `translate(-50%, -50%) rotate(${-a}deg)` }}>
<div className={scrollTurn ? 'kitfx-orbit-upright' : undefined} style={{ animation: `kitfx-orbit-spin ${dur}s linear infinite ${counter}` }}>{item}</div>
</div>
</div>
);
})}
</div>
);
})}
{center && <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">{center}</div>}
</div>
);
}
/** A round chip for orbit items: an icon or initials on a card surface */
export function OrbitItem({ className, children }: { className?: string; children: React.ReactNode }) {
return (
<span className={cn('grid size-11 place-items-center rounded-full border border-border bg-card text-foreground shadow-[var(--highlight),var(--shadow-md)] sm:size-12', className)}>{children}</span>
);
}Frequent questions
How do I install Orbit?
With aiw add @ai-website/orbit. The command puts components/ui/orbit.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Orbit have?
3 variants, Integrations, Halo behind the copy and People.
Can an AI assistant build with Orbit?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Orbit with aiw_components and install it with aiw_add @ai-website/orbit.