Domphy

orbitingCircles

A Core block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call orbitingCircles() with no arguments for a working demo, or edit the code below live.

Implementation notes

Full upright-glyph rotate->translateX->counter-rotate CSS @keyframes trick as specified, with animation-direction for counterclockwise, negative animation-delay evenly distributed across items (or per-item override), a dashed orbit-guide circle, and a prefers-reduced-motion pause. Deviates from upstream's API shape deliberately: rather than each ring being a separate component instance meant to be manually stacked with siblings sharing an external container, this single factory renders one complete, self-contained ring (with an optional non-orbiting center hub glyph) so orbitingCircles() alone is a working 'hub and spoke' demo per the package's factory-function contract. Multiple rings can still be composed by calling it multiple times and positioning the returned trees, but that composition is left to the caller rather than baked into a multi-ring prop.

Status: ported · Reference: Magic UI original

// magicui "Orbiting Circles" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// decorative "hub and spoke" layout: a fixed center element with one or more
// icon chips continuously orbiting it at a constant angular velocity.
//
// The upright-glyph trick: each orbiting chip's CSS `@keyframes` animate the
// full `transform` shorthand from `rotate(0) translateX(radius) rotate(0)` to
// `rotate(360deg) translateX(radius) rotate(-360deg)`. Browsers interpolate
// matching transform-function lists position-by-position, so the two
// `rotate()` calls animate independently of the `translateX()` between them —
// the outer rotate sweeps the chip around the circle while the inner
// counter-rotate cancels that sweep out locally, keeping the chip's own
// content upright instead of spinning in place. `animation-direction: reverse`
// flips the whole loop, which reads as counterclockwise motion for a
// symmetric infinite loop. Per-item `animation-delay` is negative and
// distributed evenly across `duration`, so items appear pre-spread around the
// ring instead of clumping together at mount.

import type { DomphyElement, Listener, StyleObject } from "@domphy/core";
import { hashString } from "@domphy/core";
import { themeColor, themeSpacing } from "@domphy/theme";

export interface OrbitingCircleItem {
  /** Content rendered inside this orbiting slot — an icon glyph or arbitrary node. */
  content: DomphyElement;
  /** Start-offset in seconds along the ring, overriding the automatic even spacing. */
  delay?: number;
}

export interface OrbitingCirclesProps {
  /** Items placed evenly around the ring. Defaults to 6 generic icon-chip placeholders. */
  items?: (DomphyElement | OrbitingCircleItem)[];
  /** Element pinned at the shared center point — does not orbit. Pass `null` to omit. Defaults to a small hub glyph. */
  center?: DomphyElement | null;
  /** Orbiting chip box size, in `themeSpacing` units (≈30px at the default). Defaults to 7.5. */
  iconSizeUnits?: number;
  /** Ring radius, in px. Defaults to 160. */
  radius?: number;
  /** Seconds per full revolution. Defaults to 20. */
  duration?: number;
  /** Counterclockwise instead of clockwise. Defaults to false. */
  reverse?: boolean;
  /** Multiplies angular velocity (shrinks the effective revolution time). Defaults to 1. */
  speed?: number;
  /** Renders the faint dashed orbit guide circle. Defaults to true. */
  path?: boolean;
  /** Passthrough style merged onto the outer (relative, overflow-hidden) container. */
  style?: StyleObject;
}

const DEFAULT_ICON_SIZE_UNITS = 7.5;
const DEFAULT_RADIUS = 160;
const DEFAULT_DURATION = 20;

// Hand-authored, simple geometric glyph shapes (24x24, stroke=currentColor) —
// generic placeholders for "an icon goes here", not tracing any icon library.
const DEFAULT_GLYPH_SHAPES: DomphyElement[][] = [
  [{ circle: null, cx: "12", cy: "12", r: "7" }],
  [{ rect: null, x: "5", y: "5", width: "14", height: "14", rx: "2" }],
  [{ polygon: null, points: "12,4 20,19 4,19" }],
  [{ polygon: null, points: "12,3 21,12 12,21 3,12" }],
  [{ path: null, d: "M13 3 5 14h6l-1 7 9-11h-6l1-7z" }],
  [{ path: null, d: "M12 3s7 7.5 7 12a7 7 0 1 1-14 0c0-4.5 7-12 7-12z" }],
];

function orbitGlyph(shape: DomphyElement[]): DomphyElement<"svg"> {
  return {
    svg: shape,
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: "1.75",
    strokeLinecap: "round",
    strokeLinejoin: "round",
    role: "presentation",
    ariaHidden: "true",
    style: { width: "55%", height: "55%" },
  } as DomphyElement<"svg">;
}

function defaultItems(): OrbitingCircleItem[] {
  return DEFAULT_GLYPH_SHAPES.map((shape) => ({ content: orbitGlyph(shape) }));
}

/** Small hub glyph pinned at the shared center — a generic 4-point sparkle. */
function defaultCenterGlyph(): DomphyElement<"svg"> {
  return {
    svg: [{ path: null, d: "M12 2c0 5.5 4.5 10 10 10-5.5 0-10 4.5-10 10 0-5.5-4.5-10-10-10 5.5 0 10-4.5 10-10z" }],
    viewBox: "0 0 24 24",
    fill: "currentColor",
    role: "presentation",
    ariaHidden: "true",
    style: { width: "60%", height: "60%" },
  } as DomphyElement<"svg">;
}

function isOrbitingCircleItem(entry: DomphyElement | OrbitingCircleItem): entry is OrbitingCircleItem {
  return typeof entry === "object" && entry !== null && "content" in entry;
}

/** Builds the shared `@keyframes` for one ring — same radius means the same
 * rule can be reused by every item on that ring (browser dedupes by name). */
function buildOrbitKeyframes(radius: number): { name: string; rules: Record<string, StyleObject> } {
  const rules = {
    "0%": { transform: `rotate(0deg) translateX(${radius}px) rotate(0deg)` },
    "100%": { transform: `rotate(360deg) translateX(${radius}px) rotate(-360deg)` },
  };
  return { name: `orbit-${hashString(JSON.stringify(rules))}`, rules };
}

function orbitItemElement(
  entry: DomphyElement | OrbitingCircleItem,
  index: number,
  count: number,
  iconSizeUnits: number,
  effectiveDuration: number,
  reverse: boolean,
  keyframeName: string,
  keyframeRules: Record<string, StyleObject>,
): DomphyElement<"div"> {
  const item = isOrbitingCircleItem(entry) ? entry : { content: entry };
  const delaySeconds = item.delay ?? (effectiveDuration / count) * index;

  return {
    div: [item.content],
    _key: `orbit-item-${index}`,
    ariaHidden: "true",
    dataTone: "shift-1",
    style: {
      position: "absolute",
      insetBlockStart: "50%",
      insetInlineStart: "50%",
      translate: "-50% -50%",
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      width: themeSpacing(iconSizeUnits),
      height: themeSpacing(iconSizeUnits),
      borderRadius: themeSpacing(2),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3")}`,
      outlineOffset: "-1px",
      boxShadow: (listener: Listener) =>
        `0 ${themeSpacing(1)} ${themeSpacing(3)} ${themeColor(listener, "shift-4")}`,
      willChange: "transform",
      animationName: keyframeName,
      animationDuration: `${effectiveDuration}s`,
      animationTimingFunction: "linear",
      animationIterationCount: "infinite",
      animationDirection: reverse ? "reverse" : "normal",
      animationDelay: `${-delaySeconds}s`,
      [`@keyframes ${keyframeName}`]: keyframeRules,
      "@media (prefers-reduced-motion: reduce)": { animationPlayState: "paused" },
    } as StyleObject,
  };
}

/** Faint dashed guide circle showing the ring boundary — pure decoration, no text of its own. */
function orbitPathElement(radius: number): DomphyElement<"div"> {
  const element = {
    div: null,
    _key: "orbit-path",
    ariaHidden: "true",
    _doctorDisable: "missing-color",
    style: {
      position: "absolute",
      insetBlockStart: "50%",
      insetInlineStart: "50%",
      translate: "-50% -50%",
      width: `${radius * 2}px`,
      height: `${radius * 2}px`,
      borderRadius: "50%",
      border: (listener: Listener) => `1px dashed ${themeColor(listener, "shift-3")}`,
      pointerEvents: "none",
    },
  };
  return element as DomphyElement<"div">;
}

function centerElement(content: DomphyElement, iconSizeUnits: number): DomphyElement<"div"> {
  return {
    div: [content],
    _key: "orbit-center",
    ariaHidden: "true",
    dataTone: "shift-16",
    style: {
      position: "absolute",
      insetBlockStart: "50%",
      insetInlineStart: "50%",
      translate: "-50% -50%",
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      width: themeSpacing(iconSizeUnits * 1.7),
      height: themeSpacing(iconSizeUnits * 1.7),
      borderRadius: "50%",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      boxShadow: (listener: Listener) =>
        `0 ${themeSpacing(2)} ${themeSpacing(6)} ${themeColor(listener, "shift-4")}`,
      zIndex: 1,
    },
  };
}

/**
 * A decorative "hub and spoke" layout: icon chips continuously orbiting a
 * fixed center point at constant angular velocity, with an upright-glyph
 * counter-rotation trick and evenly staggered start delays. Purely visual —
 * runs automatically on mount and pauses under `prefers-reduced-motion`.
 * Call with no arguments for a working demo — a hub glyph with 6 icon chips
 * orbiting it.
 */
function orbitingCircles(props: OrbitingCirclesProps = {}): DomphyElement<"div"> {
  const items = props.items ?? defaultItems();
  const center = props.center === undefined ? defaultCenterGlyph() : props.center;
  const iconSizeUnits = props.iconSizeUnits ?? DEFAULT_ICON_SIZE_UNITS;
  const radius = props.radius ?? DEFAULT_RADIUS;
  const duration = props.duration ?? DEFAULT_DURATION;
  const reverse = props.reverse ?? false;
  const speed = props.speed && props.speed > 0 ? props.speed : 1;
  const showPath = props.path ?? true;

  const effectiveDuration = duration / speed;
  const { name: keyframeName, rules: keyframeRules } = buildOrbitKeyframes(radius);

  const children: DomphyElement[] = [];
  if (showPath) children.push(orbitPathElement(radius));
  if (center) children.push(centerElement(center, iconSizeUnits));
  items.forEach((entry, index) => {
    children.push(
      orbitItemElement(entry, index, items.length, iconSizeUnits, effectiveDuration, reverse, keyframeName, keyframeRules),
    );
  });

  return {
    div: children,
    role: "img",
    ariaLabel: "Orbiting icons around a central hub",
    style: {
      position: "relative",
      width: `${radius * 2}px`,
      height: `${radius * 2}px`,
      maxWidth: "100%",
      padding: themeSpacing(iconSizeUnits),
      marginInline: "auto",
      overflow: "hidden",
      boxSizing: "content-box",
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { orbitingCircles };

← Back to Magic UI catalog