PulseLoader
PreviewCode
// preview
Grow
outin
Color from
#8e8e97
Color mid
#dbc2ff
Color to
#ffffff
// install
pnpmnpmyarnbun
npx shadcn@latest add "https://designpass.dev/r/PulseLoader-TS-TW.json"Install the PulseLoader component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/PulseLoader-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
/*!
* PulseLoader, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/pulse-loader
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useRef, type CSSProperties } from "react";
export interface PulseLoaderProps {
/** Diameter of the spinner (px). */
size?: number;
/** Seconds for one full trip around the dial. */
speed?: number;
/** Width of each ray (px). */
stroke?: number;
/** Ray length at rest, as a fraction of the radius (0-1). */
startLength?: number;
/** Ray length at the pulse crest (the taller end), as a fraction of the radius (0-1). */
endLength?: number;
/** Whether rays extend from the rim toward the center, or from the center out toward the rim. */
grow?: "inward" | "outward";
/** Resting color of a ray, our dim base tone. */
colorFrom?: string;
/** Accent color a ray passes through as it brightens. */
colorMid?: string;
/** Peak color at the crest of the pulse. */
colorTo?: string;
className?: string;
style?: CSSProperties;
}
const RAYS = 12;
/**
* Twelve rays arranged on a dial. A crest travels around the ring: rather than
* fading each ray with opacity, every ray is recolored through a three-stop
* gradient (dim base, accent, bright peak) as it stretches and relaxes. Each
* ray runs one Web Animations API loop with a staggered negative delay, so the
* wave is seamless. Zero dependencies, honors prefers-reduced-motion.
*/
export default function PulseLoader({
size = 40,
speed = 1,
stroke = 3,
startLength = 0.24,
endLength = 0.45,
grow = "outward",
colorFrom = "#8e8e97",
colorMid = "#dbc2ff",
colorTo = "#ffffff",
className = "",
style,
}: PulseLoaderProps) {
const bars = useRef<(HTMLSpanElement | null)[]>([]);
const peakHeight = endLength * (size / 2);
const restHeight = startLength * (size / 2);
// Half the stroke width gives a semicircular cap on each end (pill shape).
const capRadius = stroke / 2;
// Both modes anchor one end on a fixed ring and grow the other end:
// - inward: outer end pinned to the rim, inner end grows toward center.
// - outward: inner end pinned to a ring `peakHeight` in from the rim, outer
// end grows out toward the rim (the mirror image, not a burst from a point).
const outward = grow === "outward";
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
// Height carries the stretch (not scaleY, which would squash the rounded
// caps flat). backgroundColor carries the gradient sweep that used to be an
// opacity fade. The two ride the same timeline.
const frames: Keyframe[] = [
{ offset: 0, height: `${restHeight}px`, backgroundColor: colorFrom },
{ offset: 0.1, backgroundColor: colorMid },
{ offset: 0.2, height: `${peakHeight}px`, backgroundColor: colorTo },
{ offset: 0.35, backgroundColor: colorMid },
{ offset: 0.5, height: `${restHeight}px`, backgroundColor: colorFrom },
{ offset: 1, height: `${restHeight}px`, backgroundColor: colorFrom },
];
const animations = bars.current.map((bar, index) =>
bar?.animate(frames, {
duration: speed * 1000,
iterations: Infinity,
easing: "ease-in-out",
delay: (-speed / RAYS) * index * 1000,
}),
);
return () => animations.forEach((animation) => animation?.cancel());
}, [speed, restHeight, peakHeight, colorFrom, colorMid, colorTo]);
return (
<span
role="status"
aria-label="Loading"
className={`relative inline-flex shrink-0 items-center justify-center ${className}`}
style={{ width: size, height: size, ...style }}
>
{Array.from({ length: RAYS }).map((_, index) => (
<span
key={index}
aria-hidden="true"
className="absolute top-0 h-full"
style={{ width: stroke, left: `calc(50% - ${stroke / 2}px)`, transform: `rotate(${(-360 / RAYS) * index}deg)` }}
>
<span
ref={(el) => {
bars.current[index] = el;
}}
className="absolute block w-full"
style={{
height: restHeight,
borderRadius: capRadius,
backgroundColor: colorFrom,
...(outward ? { bottom: size - peakHeight } : { top: 0 }),
}}
/>
</span>
))}
</span>
);
}
// props
Need the license details? Read the component license.