Flywheel
Four metallic CSS spinners — ring, dots-orbit, segmented, dual-ring — selected by variant. currentColor-driven, zero dependencies, gentle opacity pulse under reduced motion.
cssfree
import * as React from "react";
import { cn } from "@/lib/utils";
export interface FlywheelProps extends React.ComponentPropsWithoutRef<"div"> {
/**
* Which spinner to render.
* - `ring` (default): a steel ring with one amber-hot segment, heavy
* inertia easing, and a slight flicker at speed — the Crucible signature.
* - `dots-orbit`: dots fading in and out as they orbit.
* - `segmented`: a spoked wheel of alternating solid/gap segments.
* - `dual-ring`: two rings counter-rotating at different rates.
* @default "ring"
*/
variant?: "ring" | "dots-orbit" | "segmented" | "dual-ring";
/** Overall size in px. @default 40 */
size?: number;
/** Rotation speed multiplier. 1 = default, 2 = twice as fast. @default 1 */
speed?: number;
/** Accessible label announced to screen readers. @default "Loading" */
label?: string;
/** Amber accent color for the hot segment / active ring. @default "#ff6b35" */
accentColor?: string;
}
const KEYFRAMES = `
@keyframes crucible-flywheel-spin { to { transform: rotate(360deg); } }
@keyframes crucible-flywheel-spin-reverse { to { transform: rotate(-360deg); } }
@keyframes crucible-flywheel-flicker {
0%, 21%, 23%, 46%, 48%, 100% { opacity: 1; }
22%, 47% { opacity: 0.55; }
}
@keyframes crucible-flywheel-dot {
0%, 100% { opacity: 0.15; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.1); }
}
@keyframes crucible-flywheel-pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
[data-crucible="flywheel"] .flywheel-spin,
[data-crucible="flywheel"] .flywheel-dot {
animation: crucible-flywheel-pulse 2s ease-in-out infinite !important;
transform: none !important;
}
}
`;
const DOT_COUNT = 8;
function RingVariant({ accentColor }: { accentColor: string }) {
return (
<>
<div
className="absolute inset-0 rounded-full"
style={{ border: "3px solid currentColor", opacity: 0.18 }}
/>
<div
className="flywheel-spin absolute inset-0 rounded-full"
style={{
border: "3px solid transparent",
borderTopColor: accentColor,
animation:
"crucible-flywheel-spin calc(var(--flywheel-duration) * 1.4) cubic-bezier(0.6, 0.05, 0.2, 0.95) infinite, crucible-flywheel-flicker calc(var(--flywheel-duration) * 1.4) linear infinite",
filter: `drop-shadow(0 0 4px ${accentColor})`,
}}
/>
</>
);
}
function DotsOrbitVariant() {
return (
<div
className="flywheel-spin absolute inset-0"
style={{ animation: "crucible-flywheel-spin var(--flywheel-duration) linear infinite" }}
>
{Array.from({ length: DOT_COUNT }, (_, i) => {
const angle = (360 / DOT_COUNT) * i;
return (
// Position (rotate + radial offset) lives on this static wrapper so
// it never collides with the pulse's own `transform: scale(...)`.
<span
key={i}
className="absolute top-1/2 left-1/2 h-[14%] w-[14%]"
style={{
transform: `rotate(${angle}deg) translate(calc(var(--flywheel-size) * 0.36)) translate(-50%, -50%)`,
}}
>
<span
className="flywheel-dot block h-full w-full rounded-full"
style={{
background: "currentColor",
animation: "crucible-flywheel-dot var(--flywheel-duration) ease-in-out infinite",
animationDelay: `calc(var(--flywheel-duration) / ${DOT_COUNT} * ${i} * -1)`,
}}
/>
</span>
);
})}
</div>
);
}
function SegmentedVariant({ accentColor }: { accentColor: string }) {
return (
<div
className="flywheel-spin absolute inset-0 rounded-full"
style={{
animation: "crucible-flywheel-spin var(--flywheel-duration) linear infinite",
background: `conic-gradient(from 0deg, ${accentColor} 0deg 24deg, transparent 24deg 45deg, currentColor 45deg 69deg, transparent 69deg 90deg, currentColor 90deg 114deg, transparent 114deg 135deg, currentColor 135deg 159deg, transparent 159deg 180deg, currentColor 180deg 204deg, transparent 204deg 225deg, currentColor 225deg 249deg, transparent 249deg 270deg, currentColor 270deg 294deg, transparent 294deg 315deg, currentColor 315deg 339deg, transparent 339deg 360deg)`,
opacity: 0.9,
WebkitMask: "radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px))",
mask: "radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px))",
}}
/>
);
}
function DualRingVariant({ accentColor }: { accentColor: string }) {
return (
<>
<div
className="flywheel-spin absolute inset-0 rounded-full"
style={{
border: "3px solid transparent",
borderTopColor: accentColor,
borderBottomColor: accentColor,
animation: "crucible-flywheel-spin var(--flywheel-duration) linear infinite",
}}
/>
<div
className="flywheel-spin absolute inset-[22%] rounded-full"
style={{
border: "2px solid transparent",
borderLeftColor: "currentColor",
borderRightColor: "currentColor",
opacity: 0.7,
animation: "crucible-flywheel-spin-reverse calc(var(--flywheel-duration) * 0.7) linear infinite",
}}
/>
</>
);
}
/**
* Flywheel — four metallic CSS spinner variants selected by `variant`.
* currentColor-driven (wrap in a `text-*` class to recolor the steel track);
* the amber "hot segment" accent is independently themeable via
* `accentColor`. Zero dependencies, SSR-safe.
*
* Crucible signature (`variant="ring"`, the default): a dark steel ring with
* one amber-hot segment whose glow trails behind rotation, spinning with
* heavy inertia easing and a slight flicker at speed. Under reduced motion
* every variant collapses to a gentle opacity pulse — no spin.
*/
export function Flywheel({
variant = "ring",
size = 40,
speed = 1,
label = "Loading",
accentColor = "#ff6b35",
className,
style,
...props
}: FlywheelProps) {
const duration = `${1.1 / Math.max(speed, 0.01)}s`;
return (
<div
data-crucible="flywheel"
role="status"
aria-label={label}
className={cn("relative inline-block", className)}
style={
{
width: size,
height: size,
"--flywheel-size": `${size}px`,
"--flywheel-duration": duration,
...style,
} as React.CSSProperties
}
{...props}
>
<style>{KEYFRAMES}</style>
{variant === "ring" && <RingVariant accentColor={accentColor} />}
{variant === "dots-orbit" && <DotsOrbitVariant />}
{variant === "segmented" && <SegmentedVariant accentColor={accentColor} />}
{variant === "dual-ring" && <DualRingVariant accentColor={accentColor} />}
<span className="sr-only">{label}</span>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/flywheelHonors prefers-reduced-motion with a designed static fallback, and passes className through.