Skip to content

Orbiting Circles

Icons orbit a center element on concentric rings.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/orbiting-circles.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

  3. Copy the source

    components/ui/orbiting-circles/orbiting-circles.tsx
    "use client";
    
    import type { ComponentProps, CSSProperties, ReactNode } from "react";
    import { cn } from "@/lib/utils";
    
    export interface OrbitItem {
      /** Name shown in the tooltip and read by screen readers. */
      name: string;
      icon: ReactNode;
    }
    
    export interface OrbitRing {
      /** Ring radius in pixels at the design size; scales down with the container. */
      radius: number;
      /** Seconds per revolution. @default 30 */
      duration?: number;
      /** Orbit counter-clockwise. @default false */
      reverse?: boolean;
      /** Icon tile size in pixels at the design size. @default 40 */
      iconSize?: number;
      /** Icons on this ring, spaced evenly. */
      items: OrbitItem[];
    }
    
    export interface OrbitingCirclesProps extends Omit<ComponentProps<"div">, "children"> {
      /** Element at the center, e.g. your logo. */
      center?: ReactNode;
      /** One to three rings, innermost first. */
      rings: OrbitRing[];
      /** Draw the ring paths. @default true */
      showPaths?: boolean;
      /** Path style. @default "solid" */
      pathStyle?: "solid" | "dashed";
      /** Pause the whole system while hovered or focused. @default true */
      pauseOnHover?: boolean;
      /** Width the radii are designed for; smaller containers scale everything down. @default 480 */
      designSize?: number;
    }
    
    // Keyframes ship with the component (React 19 hoists and de-duplicates this <style>). Each icon travels its own
    // path (rotate out, then counter-rotate to stay upright), so no full-size box ever spins: a spinning ring box
    // makes browsers count its rotated bounds as page overflow and widens narrow screens.
    const KEYFRAMES =
      "@keyframes hantui-orbit-path{from{transform:rotate(var(--a)) translateX(var(--radius)) rotate(calc(var(--a) * -1))}to{transform:rotate(calc(var(--a) + 360deg)) translateX(var(--radius)) rotate(calc(var(--a) * -1 - 360deg))}}";
    
    export function OrbitingCircles({
      center,
      rings,
      showPaths = true,
      pathStyle = "solid",
      pauseOnHover = true,
      designSize = 480,
      className,
      style,
      ...props
    }: OrbitingCirclesProps) {
      return (
        <div
          className={cn("group/orbit relative mx-auto aspect-square w-full [container-type:inline-size]", className)}
          style={{ maxWidth: designSize, "--orbit-unit": `min(1px, 100cqw / ${designSize})`, ...style } as CSSProperties}
          {...props}
        >
          <style href="hantui-orbiting-circles" precedence="hantui">
            {KEYFRAMES}
          </style>
          {rings.map((ring, r) => {
            const duration = ring.duration ?? 30;
            const size = ring.iconSize ?? 40;
            const animation = {
              animationDuration: `${duration}s`,
              animationDirection: ring.reverse ? "reverse" : "normal",
            };
            const paused = pauseOnHover
              ? "group-hover/orbit:[animation-play-state:paused] group-focus-within/orbit:[animation-play-state:paused]"
              : "";
            return (
              <div key={r} className="pointer-events-none absolute inset-0" style={{ "--radius": `calc(${ring.radius} * var(--orbit-unit))` } as CSSProperties}>
                {showPaths ? (
                  <span
                    aria-hidden="true"
                    className={cn(
                      "absolute top-1/2 left-1/2 size-[calc(var(--radius)*2)] -translate-x-1/2 -translate-y-1/2 rounded-full border border-border",
                      pathStyle === "dashed" && "border-dashed",
                    )}
                  />
                ) : null}
                <ul aria-label={`Orbit ${r + 1}`} className="absolute top-1/2 left-1/2 size-0">
                  {ring.items.map((item, i) => {
                    const angle = (360 / ring.items.length) * i;
                    return (
                      <li
                        key={item.name}
                        className={cn(
                          "absolute top-0 left-0 [animation-iteration-count:infinite] [animation-name:hantui-orbit-path] [animation-timing-function:linear] motion-reduce:[animation-name:none]",
                          paused,
                        )}
                        // The inline transform is the resting position, used when reduced motion turns the animation off.
                        style={{ "--a": `${angle}deg`, transform: `rotate(${angle}deg) translateX(var(--radius)) rotate(${-angle}deg)`, ...animation } as CSSProperties}
                      >
                        <span
                            tabIndex={0}
                            className="group/icon pointer-events-auto relative -mt-[calc(var(--s)/2)] -ml-[calc(var(--s)/2)] grid size-[var(--s)] place-items-center rounded-xl border border-border bg-background text-foreground shadow-sm outline-none transition-transform duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] hover:scale-110 focus-visible:scale-110 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none [&>svg]:size-[55%]"
                            style={{ "--s": `calc(${size} * var(--orbit-unit))` } as CSSProperties}
                          >
                            {item.icon}
                            <span className="sr-only">{item.name}</span>
                            <span
                              aria-hidden="true"
                              className="pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 translate-y-1 rounded-md bg-foreground px-2 py-1 text-[11px] font-medium whitespace-nowrap text-background opacity-0 transition-[opacity,transform] duration-150 group-hover/icon:translate-y-0 group-hover/icon:opacity-100 group-focus-visible/icon:translate-y-0 group-focus-visible/icon:opacity-100"
                            >
                              {item.name}
                            </span>
                        </span>
                      </li>
                    );
                  })}
                </ul>
              </div>
            );
          })}
          {center ? <div className="absolute top-1/2 left-1/2 z-10 -translate-x-1/2 -translate-y-1/2">{center}</div> : null}
        </div>
      );
    }

Usage

import { OrbitingCircles } from "@/components/ui/orbiting-circles/orbiting-circles";

<OrbitingCircles
  center={<YourLogo />}
  rings={[
    { radius: 110, duration: 26, items: [{ name: "Slack", icon: <SlackIcon /> }] },
    { radius: 200, duration: 40, reverse: true, items: moreApps },
  ]}
  pathStyle="dashed"
/>

API reference

PropTypeDefaultDescription
centerReactNode–Element at the center, e.g. your logo.
rings*OrbitRing[]–One to three rings, innermost first.
showPathsbooleantrueDraw the ring paths.
pathStyle"solid" | "dashed""solid"Path style.
pauseOnHoverbooleantruePause the whole system while hovered or focused.
designSizenumber480Width the radii are designed for; smaller containers scale everything down.

Also accepts every native <div> attribute, including ref and className.

  • Pure CSS keyframes: each icon follows its own circular path (rotate out to the ring, then counter-rotate to stay upright), so no full-size box ever spins and the rings never widen the page on narrow screens. There's no per-frame JavaScript. The keyframes ship inside the component through a React 19 hoisted <style>.
  • Radii and icon sizes are given for designSize (480px by default) and scale down with the container through container query units, so it works on phones.

Accessibility

  • Each icon is focusable and named; hover or focus scales it up and shows a tooltip with its name.
  • Hovering the system or focusing an icon pauses every ring, so names can be read and icons reached.
  • With reduced motion nothing orbits: icons sit at their evenly spaced positions.