Home/Components/Loaders/Orbit Frame Loader - React Loading Animation
Code · Live Preview
Componenttsx
// src/components/OrbitFrameLoader/OrbitFrameLoader.tsx
import { useId } from "react";
import { motion, useReducedMotion } from "framer-motion";

type OrbitFrameLoaderProps = {
  size?: number;
  label?: string;
  className?: string;
};

type Track = {
  /** Distance from the edge of the loader, in % of its size */
  inset: number;
  /** Dot radius in viewBox units (viewBox is 100 x 100) */
  dotRadius: number;
  color: string;
  /** Seconds for one full lap */
  duration: number;
  reverse: boolean;
  /** Negative start time so the dots don't all begin at the same spot */
  begin: number;
};

const TRACKS: Track[] = [
  {
    inset: 3,
    dotRadius: 2.3,
    color: "#a5f3fc",
    duration: 4.8,
    reverse: false,
    begin: 0,
  },
  {
    inset: 18,
    dotRadius: 1.7,
    color: "#f5d0fe",
    duration: 3.6,
    reverse: true,
    begin: -1.2,
  },
  {
    inset: 33,
    dotRadius: 1.7,
    color: "#a7f3d0",
    duration: 2.7,
    reverse: false,
    begin: -0.9,
  },
];

/** Rounded square path (30% corner radius), clockwise from the top edge */
function trackPath(inset: number) {
  const x = inset;
  const y = inset;
  const s = 100 - inset * 2;
  const r = s * 0.3;

  return [
    `M ${x + r} ${y}`,
    `H ${x + s - r}`,
    `A ${r} ${r} 0 0 1 ${x + s} ${y + r}`,
    `V ${y + s - r}`,
    `A ${r} ${r} 0 0 1 ${x + s - r} ${y + s}`,
    `H ${x + r}`,
    `A ${r} ${r} 0 0 1 ${x} ${y + s - r}`,
    `V ${y + r}`,
    `A ${r} ${r} 0 0 1 ${x + r} ${y}`,
    "Z",
  ].join(" ");
}

export function OrbitFrameLoader({
  size = 176,
  label = "Loading",
  className = "",
}: OrbitFrameLoaderProps) {
  const uid = useId().replace(/:/g, "");
  const reduceMotion = useReducedMotion() ?? false;

  return (
    <div
      role="status"
      aria-label={label || "Loading"}
      className={`inline-flex flex-col items-center gap-5 ${className}`}
    >
      <div
        aria-hidden="true"
        className="relative aspect-square shrink-0"
        style={{
          width: size,
          height: size,
          maxWidth: "80vw",
          maxHeight: "80vw",
        }}
      >
        {/* Soft glow behind the tracks */}
        <div className="absolute inset-[25%] rounded-full bg-cyan-400/10 blur-2xl" />

        <svg
          viewBox="0 0 100 100"
          className="absolute inset-0 h-full w-full overflow-visible"
          fill="none"
        >
          <defs>
            <filter
              id={`dot-glow-${uid}`}
              x="-200%"
              y="-200%"
              width="500%"
              height="500%"
            >
              <feGaussianBlur stdDeviation="1.6" result="blur" />
              <feMerge>
                <feMergeNode in="blur" />
                <feMergeNode in="blur" />
                <feMergeNode in="SourceGraphic" />
              </feMerge>
            </filter>
          </defs>

          {/* Tracks — the dots travel along these exact paths */}
          {TRACKS.map((track, i) => (
            <path
              key={`track-${i}`}
              id={`track-${uid}-${i}`}
              d={trackPath(track.inset)}
              stroke="rgba(255, 255, 255, 0.1)"
              strokeWidth={1}
              vectorEffect="non-scaling-stroke"
            />
          ))}

          {/* Dots */}
          <g filter={`url(#dot-glow-${uid})`}>
            {TRACKS.map((track, i) => {
              const start = track.inset + (100 - track.inset * 2) * 0.3;

              // Reduced motion: park each dot on the top edge of its track
              if (reduceMotion) {
                return (
                  <circle
                    key={`dot-${i}`}
                    cx={start}
                    cy={track.inset}
                    r={track.dotRadius}
                    fill={track.color}
                  />
                );
              }

              return (
                <circle key={`dot-${i}`} r={track.dotRadius} fill={track.color}>
                  <animateMotion
                    dur={`${track.duration}s`}
                    begin={`${track.begin}s`}
                    repeatCount="indefinite"
                    calcMode="linear"
                    keyPoints={track.reverse ? "1;0" : "0;1"}
                    keyTimes="0;1"
                  >
                    <mpath href={`#track-${uid}-${i}`} />
                  </animateMotion>
                </circle>
              );
            })}
          </g>
        </svg>

        {/* Pulsing center */}
        <div className="absolute inset-[43%] grid place-items-center">
          <motion.div
            className="absolute inset-0 rounded-full border border-white/20 bg-white/5"
            animate={
              reduceMotion
                ? { scale: 1, opacity: 1 }
                : {
                    scale: [0.8, 1.1, 0.8],
                    opacity: [0.5, 1, 0.5],
                  }
            }
            transition={
              reduceMotion
                ? { duration: 0 }
                : { duration: 1.5, ease: "easeInOut", repeat: Infinity }
            }
          />
          <span className="relative size-1.5 rounded-full bg-white shadow-[0_0_12px_rgba(255,255,255,0.9)]" />
        </div>
      </div>

      {label && (
        <div
          aria-hidden="true"
          className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.32em] text-slate-300/80"
        >
          <span>{label}</span>

          <span className="flex items-center gap-1">
            {[0, 1, 2].map((dot) => (
              <motion.span
                key={dot}
                className="size-1 rounded-full bg-cyan-300"
                animate={
                  reduceMotion
                    ? { opacity: 0.7 }
                    : { opacity: [0.2, 1, 0.2], y: [0, -3, 0] }
                }
                transition={{
                  duration: 0.9,
                  delay: dot * 0.16,
                  ease: "easeInOut",
                  repeat: reduceMotion ? 0 : Infinity,
                }}
              />
            ))}
          </span>
        </div>
      )}
    </div>
  );
}
// Component Source: https://codekk.dev
DesktopSandpackTailwind v4

Component details

Overview, install command, dependencies, and project links.

An orbit frame loader for React built with TypeScript, Motion, SVG, and Tailwind CSS. Three nested rounded-square tracks surround a softly pulsing core, and a glowing dot travels along each track. Each dot follows its track's exact SVG path using <animateMotion> with <mpath>, so it stays on the line the whole way around, including through the rounded corners. The three dots move at different speeds (4.8s, 3.6s, and 2.7s per lap), and the middle dot runs in the opposite direction. Staggered start times spread the dots apart from the first frame, so they never bunch up. The dots are cyan, fuchsia, and emerald, and an SVG blur filter gives them a soft neon glow. Tracks use vector-effect: non-scaling-stroke, so they stay a crisp 1px line at any size. A faint cyan ambient glow sits behind the tracks, and the center core breathes in and out with a Motion scale and opacity loop. An optional label below the loader comes with three bouncing dots. All tracks are generated from a single TRACKS config array, so you can change speed, direction, color, dot size, or start position by editing one line. Gradient and filter IDs come from React's useId, so multiple loaders can share a page without conflicts. The wrapper uses role="status" with an accessible label, and the visual parts are hidden from screen readers. When prefers-reduced-motion is enabled, the dots stop on the top edge of their tracks and the pulse and bouncing dots stop too. It is designed for dark backgrounds and works in Vite, Create React App, and Next.js.

Install

terminal
npm
npm install framer-motion

Dependencies

framer-motion
Added Sep 27, 2026

Discussion

Feedback, implementation tips, and usage notes.

0 threads

Comments

Sign in to join the conversation