// 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.devComponent 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.
Discussion
Feedback, implementation tips, and usage notes.
Comments
Sign in to join the conversation
Similar Components
More from the same category




