"use client";
import type { CSSProperties } from "react";
import { ProgressBar as AriaProgressBar } from "react-aria-components";
import { cx } from "@/utils/cx";
/**
* Spinner — an orbiting light for work of unknown length.
*
* // 20px, accent
* // inside a primary button
*
* The light is drawn the way Composer Loader draws its band: the same arc
* at three scales, a crisp line with a rounded head, a tight glow and a wide
* bloom, each on its own blur. The soft layers carry shorter arcs and turn a
* few degrees behind, so the sharp head always leads and the glow gathers
* just behind it like the tail of a comet. A faint track completes the
* circle. Everything is drawn in `currentColor`, so a text colour sets it.
*
* Announced as an indeterminate progress bar labelled "Loading" (override
* with `aria-label`). Pass `aria-hidden` where the surrounding control
* already says it is busy, e.g. a pending button. Reduced motion slows the
* orbit rather than stopping it: the motion is the message.
*/
export type SpinnerSize = "xs" | "sm" | "md" | "lg" | "xl";
/** Box size and line width in px. */
const SIZES: Record = {
xs: { box: 12, line: 1.5 },
sm: { box: 16, line: 2 },
md: { box: 20, line: 2 },
lg: { box: 24, line: 2.5 },
xl: { box: 32, line: 3 },
};
/** One scale of the light: a conic arc masked to a ring, blurred by its wrapper. */
function Arc({
box,
line,
width,
blur,
opacity,
arc,
lag,
}: {
box: number;
line: number;
/** Ring thickness, px. */
width: number;
blur: number;
opacity: number;
/** Arc length in degrees; the head sits at 12 o'clock before the turn. */
arc: number;
/** Degrees this layer trails the crisp head. */
lag: number;
}) {
const center = box / 2 - line / 2;
const inner = center - width / 2;
const outer = center + width / 2;
// The wrapper is grown to hold the ring plus its blur, so nothing clips.
const bleed = Math.max(0, outer + blur * 2 - box / 2);
const ring = `radial-gradient(circle, transparent ${inner}px, #000 ${inner + 0.5}px, #000 ${outer - 0.5}px, transparent ${outer}px)`;
const style: CSSProperties = {
inset: -bleed,
filter: blur > 0 ? `blur(${blur}px)` : undefined,
opacity,
rotate: `${-lag}deg`,
};
return (
);
}
export interface SpinnerProps {
/** 12, 16, 20 (default), 24, or 32px. */
size?: SpinnerSize;
/** Draw the faint full circle under the light. Default true. */
track?: boolean;
/** Accessible name. Default "Loading". */
"aria-label"?: string;
/** Hide it from assistive technology when a parent already announces the wait. */
"aria-hidden"?: boolean;
/** Colour comes from the text colour, e.g. `text-white`. Default accent. */
className?: string;
}
export function SpinnerGlyph({ size = "md", track = true, className }: Omit) {
const { box, line } = SIZES[size];
return (
{track ? (
) : null}
{/* The crisp line's rounded head, at the arc's leading end. */}
);
}
export function Spinner({ size = "md", track = true, "aria-label": ariaLabel = "Loading", "aria-hidden": ariaHidden, className }: SpinnerProps) {
if (ariaHidden) return ;
return (
);
}