Skip to content
UI Kit
Accent color

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

3 variants

Variants

Integrations

Two rings turning in opposite directions around your mark, next to the copy. Scrolling gives them an extra turn.

Halo behind the copy

Wide dashed rings around the headline, the items pass at the edges. On phones only the rings remain.

People

Avatars on two rings above the copy, for a community or a team.

Details

What Orbit brings along, read from the registry and the source.

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

Props

The OrbitProps interface from components/ui/orbit.tsx, with the comments from the code.

  • ringsrequired
    OrbitRing[]
  • centeroptional
    React.ReactNode

    What sits in the middle, e.g. your app mark

  • sizeoptional
    number

    Largest size in px, the orbit shrinks with its container below that. Default 520

  • showRingsoptional
    boolean

    Draw the rings as hairlines, default true

  • dashedoptional
    boolean

    Dashed rings instead of solid

  • scrollTurnoptional
    number

    Degrees the rings turn on top of their run over a full scroll pass, items stay upright. Default 0 (off), 60 reads well.

  • labeloptional
    string

    Read 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
components/ui/orbit.tsx
'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.

Often searched

Jump to

Copied to the clipboard