// ~/src/siteplane/components/spinner/spinner.tsx import { Loader2Icon } from "lucide-react"; import type React from "react"; import { cn } from "../../system/utils/cn"; import { slotClass } from "../../system/utils/slot-class"; import "./spinner.css"; export type SpinnerVariant = "default" | "ring" | "dots" | "bars" | "spokes" | "pulse" | "comet"; /** The variants that draw an svg; the others are a div around their shapes. */ type SpinnerSvgVariant = "default" | "comet"; /** * The attributes and the ref of the element the variant renders: an svg for * `default` and `comet`, a div for the others. */ export type SpinnerProps = (V extends SpinnerSvgVariant ? React.ComponentProps<"svg"> : React.ComponentProps<"div">) & { variant?: V; }; export function Spinner( allProps: SpinnerProps, ): React.ReactElement { // The variants share one body; each renders its own element type, which the // props above already say, so the rest props are read loosely here. const { className, variant = "default", ...props } = allProps as React.ComponentProps<"div"> & { variant?: SpinnerVariant }; if (variant === "default") { return ( )} /> ); } if (variant === "comet") { return ( )} > ); } const shared = { "aria-label": "Loading", "data-slot": "spinner", "data-variant": variant, role: "status" as const, ...props, }; if (variant === "ring") { return (
); } if (variant === "pulse") { return (
); } if (variant === "dots") { return (
{/* Their delays are the stylesheet's (spinner.css). */} {[0, 1, 2].map((index) => ( ))}
); } if (variant === "bars") { return (
{[0, 1, 2, 3].map((index) => ( ))}
); } return (
{/* Their angles and delays are the stylesheet's (spinner.css). */} {Array.from({ length: 8 }, (_, index) => ( ))}
); } // ~/src/siteplane/components/spinner/spinner.css @layer theme, base, components, siteplane, utilities; @layer siteplane { @keyframes sp-spinner-bars { 0%, 100% { transform: scaleY(0.4); } 50% { transform: scaleY(1); } } @keyframes sp-spinner-dot { 0%, 80%, 100% { opacity: 0.35; transform: scale(0.6); } 40% { opacity: 1; transform: scale(1); } } @keyframes sp-spinner-spoke { 0% { opacity: 1; } 100% { opacity: 0.2; } } @keyframes sp-spinner-rotate { to { transform: rotate(360deg); } } @keyframes sp-spinner-dash { 0% { stroke-dasharray: 1, 200; stroke-dashoffset: 0; } 50% { stroke-dasharray: 89, 200; stroke-dashoffset: -35; } 100% { stroke-dasharray: 89, 200; stroke-dashoffset: -124; } } @keyframes sp-spinner-ping { 75%, 100% { opacity: 0; transform: scale(2); } } /* Each variant's motion, at zero specificity so the reduced-motion rules * below and a consumer's animate-* utility both win. */ :where(.sp-spinner:is([data-variant="default"], [data-variant="ring"])) { animation: sp-spinner-rotate var(--motion-duration-loop) var(--motion-ease-linear) infinite; } :where(.sp-spinner[data-variant="comet"]) { animation: sp-spinner-rotate calc(var(--motion-duration-loop) * 2) var(--motion-ease-linear) infinite; } :where(.sp-spinner[data-variant="comet"] > circle) { animation: sp-spinner-dash calc(var(--motion-duration-loop) * 1.5) var(--motion-ease-loop) infinite; } :where(.sp-spinner[data-variant="pulse"] > :first-child) { animation: sp-spinner-ping var(--motion-duration-loop) var(--motion-ease-ping) infinite; } :where(.sp-spinner[data-variant="dots"] > span) { animation: sp-spinner-dot calc(var(--motion-duration-loop) * 1.2) var(--motion-ease-loop) infinite; } :where(.sp-spinner[data-variant="bars"] > span) { animation: sp-spinner-bars var(--motion-duration-loop) var(--motion-ease-loop) infinite; } :where(.sp-spinner[data-variant="spokes"] > span) { animation: sp-spinner-spoke calc(var(--motion-duration-loop) * 0.8) var(--motion-ease-linear) infinite; } /* Each dot and bar starts a step after the one before it. The eight spokes * stand 45 degrees apart, each a tenth of the loop later, and start mid-loop * so they never pulse together. These values live here rather than in style * attributes, which a Content Security Policy may block. */ :where(.sp-spinner[data-variant="dots"] > span:nth-child(2)) { animation-delay: calc(var(--motion-duration-loop) * 0.16); } :where(.sp-spinner[data-variant="dots"] > span:nth-child(3)) { animation-delay: calc(var(--motion-duration-loop) * 0.32); } :where(.sp-spinner[data-variant="bars"] > span:nth-child(2)) { animation-delay: calc(var(--motion-duration-loop) * 0.12); } :where(.sp-spinner[data-variant="bars"] > span:nth-child(3)) { animation-delay: calc(var(--motion-duration-loop) * 0.24); } :where(.sp-spinner[data-variant="bars"] > span:nth-child(4)) { animation-delay: calc(var(--motion-duration-loop) * 0.36); } :where(.sp-spinner[data-variant="spokes"] > span:nth-child(1)) { animation-delay: calc(var(--motion-duration-loop) * -0.8); } :where(.sp-spinner[data-variant="spokes"] > span:nth-child(2)) { rotate: 45deg; animation-delay: calc(var(--motion-duration-loop) * -0.7); } :where(.sp-spinner[data-variant="spokes"] > span:nth-child(3)) { rotate: 90deg; animation-delay: calc(var(--motion-duration-loop) * -0.6); } :where(.sp-spinner[data-variant="spokes"] > span:nth-child(4)) { rotate: 135deg; animation-delay: calc(var(--motion-duration-loop) * -0.5); } :where(.sp-spinner[data-variant="spokes"] > span:nth-child(5)) { rotate: 180deg; animation-delay: calc(var(--motion-duration-loop) * -0.4); } :where(.sp-spinner[data-variant="spokes"] > span:nth-child(6)) { rotate: 225deg; animation-delay: calc(var(--motion-duration-loop) * -0.3); } :where(.sp-spinner[data-variant="spokes"] > span:nth-child(7)) { rotate: 270deg; animation-delay: calc(var(--motion-duration-loop) * -0.2); } :where(.sp-spinner[data-variant="spokes"] > span:nth-child(8)) { rotate: 315deg; animation-delay: calc(var(--motion-duration-loop) * -0.1); } html[data-motion-reduce="true"] .sp-spinner-motion, html[data-motion-reduce="true"] .sp-spinner-motion *, [data-sp-motion="reduced"] .sp-spinner-motion, [data-sp-motion="reduced"] .sp-spinner-motion * { animation: none; } @media (prefers-reduced-motion: reduce) { .sp-spinner-motion, .sp-spinner-motion * { animation: none; } } } // ~/src/siteplane/components/spinner/index.ts export * from "./spinner";