ChaosLoader
PreviewCode
// preview
Color from
#8e8e97
Color mid
#dbc2ff
Color to
#ffffff
// install
pnpmnpmyarnbun
npx shadcn@latest add "https://designpass.dev/r/ChaosLoader-TS-TW.json"Install the ChaosLoader component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/ChaosLoader-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
/*!
* ChaosLoader, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/chaos-loader
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useMemo, useRef, type CSSProperties } from "react";
export interface ChaosLoaderProps {
/** Diameter of the spinner (px). */
size?: number;
/** Number of orbiting particles. */
count?: number;
/** Seconds for one full orbit of a particle. */
speed?: number;
/** Gradient tone at the near side of an orbit (dim base). */
colorFrom?: string;
/** Gradient accent, passed through mid-orbit. */
colorMid?: string;
/** Gradient tone at the far side of an orbit (bright peak). */
colorTo?: string;
className?: string;
style?: CSSProperties;
}
// Each particle sweeps the full diameter of its spoke, so a spoke at angle t
// and one at t+180 trace the same line. Dots clump when two spokes share a
// line or two dots cross the center together. Spreading spokes evenly across
// 180 degrees gives every dot its own line, and spreading phases evenly (but
// visited in a shuffled order) staggers the center crossings.
const GOLDEN = 0.618033988749895;
// Stride coprime to n and nearest the golden ratio: stepping phases by it
// visits every slot exactly once, in an order that never runs sequentially,
// so neighboring spokes don't ripple like a wave.
function phaseStride(n: number): number {
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b));
let best = 1;
for (let k = 1; k < n; k += 1) {
if (gcd(k, n) === 1 && Math.abs(k / n - GOLDEN) < Math.abs(best / n - GOLDEN)) best = k;
}
return best;
}
/**
* A swarm of particles orbiting a shared center, the whole cloud rotating
* slowly on top. Each particle slides along its own spoke on a cosine and
* shrinks with depth on a sine; instead of fading with opacity at the near
* side, it is recolored through a three-stop gradient (bright far, accent mid,
* dim base near). Every particle is one Web Animations API loop with a
* scattered phase, and one more loop spins the container. Zero dependencies,
* honors prefers-reduced-motion.
*/
export default function ChaosLoader({
size = 45,
count = 4,
speed = 1.75,
colorFrom = "#8e8e97",
colorMid = "#dbc2ff",
colorTo = "#ffffff",
className = "",
style,
}: ChaosLoaderProps) {
const containerRef = useRef<HTMLSpanElement>(null);
const dots = useRef<(HTMLSpanElement | null)[]>([]);
const particles = useMemo(() => {
const n = Math.max(1, Math.round(count));
const stride = phaseStride(n);
return Array.from({ length: n }, (_, i) => ({
// One line per dot: spokes cover 180 degrees evenly.
angle: (i * 180) / n,
// Even phase slots visited in strided order: center crossings are
// spaced exactly 1/n of a cycle apart, never simultaneous.
phase: -((i * stride) % n) / n,
}));
}, [count]);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const amplitude = size * 0.5;
const steps = 24;
const colors = [colorMid, colorFrom, colorMid, colorTo, colorMid];
const frames: Keyframe[] = [];
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const angle = 2 * Math.PI * t;
const x = amplitude * Math.cos(angle);
const scale = 0.737 - 0.263 * Math.sin(angle);
// z-index tracks scale so the nearer (larger) particle always draws in
// front of the smaller ones it passes.
const frame: Keyframe = {
offset: t,
transform: `translateX(${x}px) scale(${scale})`,
zIndex: Math.round(scale * 1000),
};
if (i % 6 === 0) frame.backgroundColor = colors[i / 6];
frames.push(frame);
}
const animations = particles.map((particle, index) =>
dots.current[index]?.animate(frames, {
duration: speed * 1000,
iterations: Infinity,
easing: "linear",
delay: particle.phase * speed * 1000,
}),
);
const container = containerRef.current;
const spin = container?.animate(
[{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
{ duration: speed * 4000, iterations: Infinity, easing: "linear" },
);
return () => {
animations.forEach((animation) => animation?.cancel());
spin?.cancel();
};
}, [size, speed, colorFrom, colorMid, colorTo, particles]);
const dot = size * 0.175;
return (
<span
role="status"
aria-label="Loading"
className={`inline-flex shrink-0 items-center justify-center ${className}`}
style={{ width: size, height: size, ...style }}
>
<span
ref={containerRef}
aria-hidden="true"
className="relative block h-full w-full will-change-transform"
>
{particles.map((particle, index) => (
<span
key={index}
className="absolute inset-0 flex items-center justify-center"
style={{ transform: `rotate(${particle.angle}deg)` }}
>
<span
ref={(el) => {
dots.current[index] = el;
}}
className="block rounded-full"
style={{ width: dot, height: dot, backgroundColor: colorMid }}
/>
</span>
))}
</span>
</span>
);
}
// props
Need the license details? Read the component license.