CometSpinner
PreviewCode
// preview
Color from
#8e8e97
Color mid
#dbc2ff
Color to
#ffffff
// install
pnpmnpmyarnbun
npx shadcn@latest add "https://designpass.dev/r/CometSpinner-TS-TW.json"Install the CometSpinner component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/CometSpinner-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
/*!
* CometSpinner, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/comet-spinner
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useRef, type CSSProperties } from "react";
export interface CometSpinnerProps {
/** Diameter of the spinner (px). */
size?: number;
/** Seconds for one full revolution. */
speed?: number;
/** Ring thickness (px). */
stroke?: number;
/** Tail color, the dim base tone the comet fades into. */
colorFrom?: string;
/** Midpoint accent color of the tail. */
colorMid?: string;
/** Head color, the bright leading tip of the comet. */
colorTo?: string;
className?: string;
style?: CSSProperties;
}
// Fraction of the ring left as an empty gap behind the head. The color arc
// occupies the rest, so the head sits at the gap's leading edge.
const GAP = 0.28;
/**
* A ring that spins like a comet: instead of fading the tail with opacity,
* the arc is painted with a three-stop conic gradient (dim base, accent, bright
* head) and masked to a hollow ring. Round caps sit on the two ends of the arc
* so the comet reads as a rounded stroke rather than a hard radial cut. One
* compositor-only rotation drives it via the Web Animations API. Zero
* dependencies, honors prefers-reduced-motion.
*/
export default function CometSpinner({
size = 40,
speed = 0.9,
stroke = 5,
colorFrom = "#8e8e97",
colorMid = "#dbc2ff",
colorTo = "#ffffff",
className = "",
style,
}: CometSpinnerProps) {
const spinnerRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const spinner = spinnerRef.current;
if (!spinner) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const spin = spinner.animate(
[{ transform: "rotate(0deg)" }, { transform: "rotate(360deg)" }],
{ duration: speed * 1000, iterations: Infinity, easing: "linear" },
);
return () => spin.cancel();
}, [speed]);
// Punch the inner disc out of the painted circle, leaving a ring `stroke`
// wide. The 1% feathered edge keeps the rim crisp without aliasing.
const hole = size / 2 - stroke;
const mask = `radial-gradient(circle ${hole}px, transparent 99%, #000 100%)`;
// Cap centers ride the ring's centerline; conic `from 90deg` puts fraction 0
// (the head) at 3 o'clock, and angle grows clockwise from there.
const radius = size / 2 - stroke / 2;
const capAt = (fraction: number) => {
const phi = ((90 + fraction * 360) * Math.PI) / 180;
return {
left: size / 2 + radius * Math.sin(phi) - stroke / 2,
top: size / 2 - radius * Math.cos(phi) - stroke / 2,
};
};
const head = capAt(1);
const tail = capAt(GAP);
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={spinnerRef}
aria-hidden="true"
className="relative block h-full w-full will-change-transform"
>
<span
className="absolute inset-0 rounded-full"
style={{
backgroundImage: `conic-gradient(from 90deg, transparent 0 ${GAP * 100}%, ${colorFrom} ${GAP * 100}%, ${colorMid} 72%, ${colorTo} 100%)`,
WebkitMask: mask,
mask,
}}
/>
<span
className="absolute rounded-full"
style={{ width: stroke, height: stroke, left: head.left, top: head.top, backgroundColor: colorTo }}
/>
<span
className="absolute rounded-full"
style={{ width: stroke, height: stroke, left: tail.left, top: tail.top, backgroundColor: colorFrom }}
/>
</span>
</span>
);
}
// props
Need the license details? Read the component license.