Orbiting Circles
Icons orbit a center element on concentric rings.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/orbiting-circles.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.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
| Prop | Type | Default | Description |
|---|---|---|---|
center | ReactNode | – | Element at the center, e.g. your logo. |
rings* | OrbitRing[] | – | One to three rings, innermost first. |
showPaths | boolean | true | Draw the ring paths. |
pathStyle | "solid" | "dashed" | "solid" | Path style. |
pauseOnHover | boolean | true | Pause the whole system while hovered or focused. |
designSize | number | 480 | Width 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.