Home/Components/Loaders/Animated Loop Loader - React JS Loading Spinner
Code · Live Preview
Componenttsx
// src/components/LoopLoader/LoopLoader.tsx
import { motion, useReducedMotion } from "framer-motion";
import { useId } from "react";

type LoopLoaderProps = {
  className?: string;
  size?: number;
  /** Seconds for one full lap */
  speed?: number;
  label?: string;
};

// Outer square path (clockwise from top-left)
const OUTER_PATH =
  "M 62 40 L 338 40 A 22 22 0 0 1 360 62 L 360 338 A 22 22 0 0 1 338 360 " +
  "L 62 360 A 22 22 0 0 1 40 338 L 40 62 A 22 22 0 0 1 62 40 Z";

// Inner square path (counter-clockwise)
const INNER_PATH =
  "M 128 108 L 272 108 A 20 20 0 0 1 292 128 L 292 272 A 20 20 0 0 1 272 292 " +
  "L 128 292 A 20 20 0 0 1 108 272 L 108 128 A 20 20 0 0 1 128 108 Z";

export function LoopLoader({
  className = "",
  size = 140,
  speed = 2.2,
  label = "Loading",
}: LoopLoaderProps) {
  const uid = useId().replace(/:/g, "");
  const reduce = useReducedMotion();

  return (
    <div
      className={`flex flex-col items-center gap-4 ${className}`}
      role="status"
      aria-label={label}
    >
      <div
        className="relative aspect-square"
        style={{ width: size, maxWidth: "100%" }}
      >
        <svg
          viewBox="0 0 400 400"
          className="h-full w-full overflow-visible"
          fill="none"
          aria-hidden="true"
        >
          <defs>
            {/* Outer gradient */}
            <linearGradient id={`go-${uid}`} x1="0" y1="0" x2="1" y2="1">
              <stop offset="0%" stopColor="#00E5DE" />
              <stop offset="50%" stopColor="#3D4DFF" />
              <stop offset="100%" stopColor="#FF3DF5" />
            </linearGradient>

            {/* Inner gradient */}
            <linearGradient id={`gi-${uid}`} x1="1" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor="#FF9F1A" />
              <stop offset="50%" stopColor="#FF2D2D" />
              <stop offset="100%" stopColor="#00E676" />
            </linearGradient>

            <filter
              id={`glow-${uid}`}
              x="-40%"
              y="-40%"
              width="180%"
              height="180%"
            >
              <feGaussianBlur stdDeviation="3" result="b" />
              <feMerge>
                <feMergeNode in="b" />
                <feMergeNode in="SourceGraphic" />
              </feMerge>
            </filter>
          </defs>

          {/* Faint tracks so the frame is always visible */}
          <path
            d={OUTER_PATH}
            stroke="currentColor"
            strokeOpacity={0.08}
            strokeWidth={22}
            strokeLinecap="round"
          />
          <path
            d={INNER_PATH}
            stroke="currentColor"
            strokeOpacity={0.08}
            strokeWidth={20}
            strokeLinecap="round"
          />

          <g filter={`url(#glow-${uid})`}>
            {/* Outer runner — clockwise */}
            <motion.path
              d={OUTER_PATH}
              stroke={`url(#go-${uid})`}
              strokeWidth={22}
              strokeLinecap="round"
              pathLength={1}
              strokeDasharray="0.28 0.72"
              initial={{ strokeDashoffset: 0 }}
              animate={reduce ? {} : { strokeDashoffset: [0, -1] }}
              transition={{
                duration: speed,
                repeat: Infinity,
                ease: "linear",
              }}
            />

            {/* Inner runner — counter-clockwise, offset */}
            <motion.path
              d={INNER_PATH}
              stroke={`url(#gi-${uid})`}
              strokeWidth={20}
              strokeLinecap="round"
              pathLength={1}
              strokeDasharray="0.22 0.78"
              initial={{ strokeDashoffset: 0.5 }}
              animate={reduce ? {} : { strokeDashoffset: [0.5, 1.5] }}
              transition={{
                duration: speed * 1.25,
                repeat: Infinity,
                ease: "linear",
              }}
            />
          </g>
        </svg>
      </div>

      {label && (
        <motion.span
          className="text-sm font-medium tracking-[0.2em] text-neutral-400 uppercase"
          animate={reduce ? {} : { opacity: [0.4, 1, 0.4] }}
          transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
        >
          {label}
        </motion.span>
      )}

      <span className="sr-only">{label}</span>
    </div>
  );
}
// Component Source: https://codekk.dev
DesktopSandpackTailwind v4

Component details

Overview, install command, dependencies, and project links.

An animated loop loader component for React JS built with TypeScript, Framer Motion, and SVG, useful as a page loader, a spinner for async states, or a splash screen indicator. Two rounded-square tracks are nested inside each other, and a glowing gradient runner travels around each one. The outer runner moves clockwise and the inner runner moves counter-clockwise at a slightly slower pace, so the two loops constantly shift against each other instead of syncing up. Each runner is a single SVG path animated through strokeDashoffset, with pathLength={1} normalizing the dash values so the runner length stays consistent at any size. The outer loop uses a cyan-to-blue-to-magenta gradient and the inner loop uses an orange-to-red-to-green gradient, both softened with a Gaussian blur glow filter. Faint background tracks use currentColor at low opacity, so the frame adapts automatically to light or dark themes. Gradient and filter IDs are generated with React's useId, so you can render multiple loaders on the same page without ID collisions. An optional pulsing text label sits below the spinner. The loader uses role="status" with an accessible label for screen readers, and all motion stops when the user has prefers-reduced-motion enabled. Size, speed, and label text are configurable via props. It works in Vite, Create React App, and Next.js.

Added Sep 27, 2026

Discussion

Feedback, implementation tips, and usage notes.

0 threads

Comments

Sign in to join the conversation