Home/Components/Loaders/Running Wheel Loader - React Hamster Loading Animation
Code · Live Preview
Componenttsx
// src/components/RunningWheelLoader/RunningWheelLoader.tsx
import { motion, useReducedMotion } from "framer-motion";
import { useMemo } from "react";

type RunningWheelLoaderProps = {
  className?: string;
  /** Width and height in px */
  size?: number;
  /** Seconds for one full turn of the wheel — the hamster's stride follows it */
  speed?: number;
  /** Fur color */
  color?: string;
  label?: string;
};

// viewBox is 200 x 200, wheel centered at (100, 100)
const CX = 100;
const CY = 100;
const RIM = 80;
const SPOKES = 12;

// Fixed hamster colors (fur comes from the color prop)
const BELLY = "#FFF1DE";
const PINK = "#F9A8C4";
const NOSE = "#F472B6";
const EYE = "#1F2937";

// Legs: hip position, which stride phase (0 or half a step), and whether
// it's on the far side (drawn behind the body, slightly faded)
const LEGS = [
  { hipX: 86, phase: 0, far: true },
  { hipX: 107, phase: Math.PI, far: true },
  { hipX: 90, phase: Math.PI, far: false },
  { hipX: 111, phase: 0, far: false },
];
const HIP_Y = 159;
const LEG_LENGTH = 13;
const STRIDE_ANGLE = (38 * Math.PI) / 180;
const SAMPLES = 16;

const round = (n: number) => Math.round(n * 100) / 100;

/** Foot positions for one stride: the leg swings back and forth from the hip */
function strideFrames(hipX: number, phase: number) {
  const x: number[] = [];
  const y: number[] = [];
  for (let k = 0; k <= SAMPLES; k++) {
    const angle = STRIDE_ANGLE * Math.sin((k / SAMPLES) * Math.PI * 2 + phase);
    x.push(round(hipX + LEG_LENGTH * Math.sin(angle)));
    y.push(round(HIP_Y + LEG_LENGTH * Math.cos(angle)));
  }
  return { x, y };
}

// Speed streaks behind the hamster: [start x, y, length, delay]
const STREAKS = [
  [66, 138, 10, 0],
  [60, 150, 13, 0.2],
  [66, 162, 9, 0.4],
] as const;

export function RunningWheelLoader({
  className = "",
  size = 180,
  speed = 2.4,
  color = "#E9A15B",
  label = "Loading",
}: RunningWheelLoaderProps) {
  const reduce = useReducedMotion();
  const stride = speed * 0.15; // seconds per step cycle

  const legs = useMemo(
    () =>
      LEGS.map((leg) => ({
        ...leg,
        frames: strideFrames(leg.hipX, leg.phase),
      })),
    [],
  );

  const renderLeg = (leg: (typeof legs)[number], i: number) => {
    const shared = {
      x1: leg.hipX,
      y1: HIP_Y,
      stroke: color,
      strokeWidth: 5,
      strokeLinecap: "round" as const,
      opacity: leg.far ? 0.7 : 1,
    };

    return reduce ? (
      <line key={i} {...shared} x2={leg.hipX} y2={HIP_Y + LEG_LENGTH} />
    ) : (
      <motion.line
        key={i}
        {...shared}
        initial={{ x2: leg.frames.x[0], y2: leg.frames.y[0] }}
        animate={{ x2: leg.frames.x, y2: leg.frames.y }}
        transition={{ duration: stride, ease: "linear", repeat: Infinity }}
      />
    );
  };

  return (
    <div
      className={`flex flex-col items-center gap-4 ${className}`}
      role="status"
      aria-label={label || "Loading"}
    >
      <div
        className="relative aspect-square"
        style={{ width: size, maxWidth: "100%" }}
      >
        <svg
          viewBox="0 0 200 200"
          className="h-full w-full overflow-visible"
          fill="none"
          aria-hidden="true"
        >
          {/* Stand */}
          <path
            d={`M ${CX} ${CY} L 66 190 M ${CX} ${CY} L 134 190 M 50 192 H 150`}
            stroke="currentColor"
            strokeOpacity={0.3}
            strokeWidth={5}
            strokeLinecap="round"
          />

          {/*
            Spinning part of the wheel: spokes and tread rungs.
            Uses native SVG <animateTransform> with an explicit rotation
            center (CX, CY). This avoids framer-motion's SVG transform-origin
            handling, which differs between versions and can fall back to
            (0, 0) inside sandboxed iframes — making the wheel orbit the
            top-left corner instead of spinning in place.
          */}
          <g>
            {!reduce && (
              <animateTransform
                attributeName="transform"
                type="rotate"
                from={`0 ${CX} ${CY}`}
                to={`360 ${CX} ${CY}`}
                dur={`${speed}s`}
                repeatCount="indefinite"
              />
            )}
            {Array.from({ length: SPOKES }, (_, i) => {
              const a = (i / SPOKES) * Math.PI * 2;
              return (
                <line
                  key={i}
                  x1={round(CX + 12 * Math.cos(a))}
                  y1={round(CY + 12 * Math.sin(a))}
                  x2={round(CX + (RIM - 6) * Math.cos(a))}
                  y2={round(CY + (RIM - 6) * Math.sin(a))}
                  stroke="currentColor"
                  strokeOpacity={0.18}
                  strokeWidth={1.6}
                />
              );
            })}
            <circle
              cx={CX}
              cy={CY}
              r={RIM - 3}
              stroke="currentColor"
              strokeOpacity={0.28}
              strokeWidth={6}
              strokeDasharray="2 7.8"
            />
          </g>

          {/* Rim and hub */}
          <circle
            cx={CX}
            cy={CY}
            r={RIM}
            stroke="currentColor"
            strokeOpacity={0.45}
            strokeWidth={4}
          />
          <circle
            cx={CX}
            cy={CY}
            r={RIM - 7}
            stroke="currentColor"
            strokeOpacity={0.15}
            strokeWidth={1.5}
          />
          <circle cx={CX} cy={CY} r={7} fill="currentColor" fillOpacity={0.4} />

          {/* Speed streaks */}
          {!reduce &&
            STREAKS.map(([x, y, length, delay], i) => (
              <motion.line
                key={i}
                x1={x}
                x2={x + length}
                y1={y}
                y2={y}
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                initial={{ x: 0, opacity: 0 }}
                animate={{ x: [0, -10], opacity: [0, 0.5, 0] }}
                transition={{
                  duration: 0.6,
                  delay,
                  ease: "easeOut",
                  repeat: Infinity,
                }}
              />
            ))}

          {/* Far legs, behind the body */}
          {legs.filter((l) => l.far).map(renderLeg)}

          {/* Hamster body — bobs slightly with every step */}
          <motion.g
            animate={reduce ? { y: 0 } : { y: [0, -1.8, 0] }}
            transition={
              reduce
                ? { duration: 0 }
                : { duration: stride / 2, ease: "easeInOut", repeat: Infinity }
            }
          >
            {/* Tail */}
            <circle cx={75} cy={146} r={3.5} fill={color} />
            {/* Body and belly */}
            <ellipse cx={98} cy={149} rx={24} ry={16} fill={color} />
            <ellipse cx={101} cy={157} rx={15} ry={7} fill={BELLY} />
            {/* Ear */}
            <circle cx={114} cy={131} r={5.5} fill={color} />
            <circle cx={114} cy={131} r={3} fill={PINK} />
            {/* Head */}
            <circle cx={121} cy={143} r={13} fill={color} />
            <ellipse cx={130} cy={148} rx={6.5} ry={5} fill={BELLY} />
            <ellipse
              cx={124}
              cy={151}
              rx={3.5}
              ry={2.2}
              fill={PINK}
              opacity={0.6}
            />
            {/* Eye */}
            <circle cx={125} cy={139.5} r={2.3} fill={EYE} />
            <circle cx={125.8} cy={138.7} r={0.8} fill="#fff" />
            {/* Nose */}
            <circle cx={136} cy={146.5} r={2} fill={NOSE} />
          </motion.g>

          {/* Near legs, in front of the body */}
          {legs.filter((l) => !l.far).map(renderLeg)}
        </svg>
      </div>

      {label && (
        <motion.span
          aria-hidden="true"
          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>
      )}
    </div>
  );
}
// Component Source: https://codekk.dev
DesktopSandpackTailwind v4

Component details

Overview, install command, dependencies, and project links.

A playful hamster wheel loader for React built with TypeScript, Framer Motion, SVG, and Tailwind CSS. A little hamster runs in an exercise wheel while the wheel turns under its feet, a friendly loading state for pet stores, fitness apps, kids' products, playful brands, and "working on it" moments. The wheel's spokes and tread rungs rotate clockwise, the direction the hamster's steps would actually push it, while the rim, hub, and stand stay still like on a real wheel. Each leg swings back and forth from the hip along a smooth arc, with diagonal legs moving together like a real trot. The far-side legs are slightly faded and drawn behind the body for a sense of depth. Leg motion is animated through each line's endpoint rather than CSS rotation, which avoids transform-origin issues and stays consistent across browsers. The body bobs slightly with every step, and speed streaks trail behind the hamster. The stride is tied to the wheel speed, so a single `speed` prop makes the hamster sprint or jog in sync with the wheel. The hamster is drawn entirely in SVG, with a rounded body and belly, an ear with a pink inner ear, a glinting eye, a snout with a pink nose, a blush, and a little tail. The `color` prop sets the fur color, so the hamster can be golden, grey, white, or a brand-colored mascot. The wheel and stand use `currentColor`, so they adapt to light or dark themes automatically. The wrapper uses `role="status"` with an accessible label, and the visuals are hidden from screen readers. When `prefers-reduced-motion` is enabled, the hamster stands still in the wheel and the wheel stops turning. 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