GyroLoader
PreviewCode
// preview
Color from
#8e8e97
Color mid
#dbc2ff
Color to
#ffffff
// install
pnpmnpmyarnbun
npx shadcn@latest add "https://designpass.dev/r/GyroLoader-TS-TW.json"Install the GyroLoader component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/GyroLoader-TS-TW.json"
If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.// source
tsjs
twcss
/*!
* GyroLoader, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/gyro-loader
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useRef, type CSSProperties } from "react";
export interface GyroLoaderProps {
/** Diameter of the outer ring (px). */
size?: number;
/** Seconds for one revolution of the outer ring. */
speed?: number;
/** Ring thickness (px). */
stroke?: number;
/** Outer ring color (dim base). */
colorFrom?: string;
/** Middle ring color (accent). */
colorMid?: string;
/** Inner ring and core color (bright peak). */
colorTo?: string;
className?: string;
style?: CSSProperties;
}
/**
* Three nested rings spinning on orthogonal axes like a gyroscope gimbal, with
* a pulsing core at the center. Each ring is a plain circular border living on
* its own preserve-3d plane under a shared perspective camera, so the rings
* genuinely pass in front of and behind each other. The rings step through the
* three-stop gradient (dim outer, accent middle, bright inner) so depth reads
* from color. One Web Animations API rotation per ring plus one core pulse.
* Zero dependencies, honors prefers-reduced-motion.
*/
export default function GyroLoader({
size = 48,
speed = 1.8,
stroke = 2,
colorFrom = "#8e8e97",
colorMid = "#dbc2ff",
colorTo = "#ffffff",
className = "",
style,
}: GyroLoaderProps) {
const rings = useRef<(HTMLSpanElement | null)[]>([]);
const coreRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// Each ring owns an axis: X, Y, and a diagonal. Alternating direction and
// slightly detuned periods keep the gimbal from ever locking into phase.
const axes = [
{ from: "rotateX(0deg)", to: "rotateX(360deg)", duration: speed },
{ from: "rotateY(0deg)", to: "rotateY(-360deg)", duration: speed * 1.35 },
{
from: "rotate3d(1, 1, 0, 0deg)",
to: "rotate3d(1, 1, 0, 360deg)",
duration: speed * 1.75,
},
];
const spins = axes.map((axis, index) =>
rings.current[index]?.animate(
[{ transform: axis.from }, { transform: axis.to }],
{ duration: axis.duration * 1000, iterations: Infinity, easing: "linear" },
),
);
const pulse = coreRef.current?.animate(
[{ transform: "scale(0.7)" }, { transform: "scale(1.15)" }, { transform: "scale(0.7)" }],
{ duration: speed * 1000, iterations: Infinity, easing: "ease-in-out" },
);
return () => {
spins.forEach((spin) => spin?.cancel());
pulse?.cancel();
};
}, [speed]);
const colors = [colorFrom, colorMid, colorTo];
const core = size * 0.14;
return (
<span
role="status"
aria-label="Loading"
className={`inline-block shrink-0 ${className}`}
style={{ width: size, height: size, perspective: size * 4, ...style }}
>
<span
aria-hidden="true"
className="relative flex h-full w-full items-center justify-center"
style={{ transformStyle: "preserve-3d" }}
>
{colors.map((color, index) => {
const diameter = size * (1 - index * 0.26);
return (
<span
key={index}
ref={(el) => {
rings.current[index] = el;
}}
className="absolute rounded-full"
style={{
width: diameter,
height: diameter,
border: `${stroke}px solid ${color}`,
transformStyle: "preserve-3d",
willChange: "transform",
}}
/>
);
})}
<span
ref={coreRef}
className="absolute rounded-full"
style={{
width: core,
height: core,
backgroundColor: colorTo,
boxShadow: `0 0 ${core}px ${colorMid}`,
willChange: "transform",
}}
/>
</span>
</span>
);
}
// props
Need the license details? Read the component license.