{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "progress-ring",
  "type": "registry:ui",
  "title": "Progress Ring",
  "description": "Circular progress with a rolling percentage that closes into a drawn tick.",
  "categories": [
    "async"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/progress-ring.tsx",
      "content": "\"use client\";\nimport { Progress } from \"@base-ui/react/progress\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\n// The indeterminate spin is a CSS loop so it keeps turning while the main thread\n// is busy. Reduced motion holds the arc still and breathes it instead.\nconst CSS = `\n@keyframes stealth-ring-turn { to { transform: rotate(360deg); } }\n@keyframes stealth-ring-breathe { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }\n.stealth-ring [data-part=\"spin\"] { animation: stealth-ring-turn 0.9s linear infinite; transform-origin: center; transform-box: fill-box; }\n@media (prefers-reduced-motion: reduce) {\n  .stealth-ring [data-part=\"spin\"] { animation: stealth-ring-breathe 2.4s ease-in-out infinite !important; }\n}\n`;\n\nconst SIZES = {\n  sm: { px: 20, stroke: 2, text: \"\" },\n  md: { px: 40, stroke: 3, text: \"text-[10.5px]\" },\n  lg: { px: 64, stroke: 4, text: \"text-[14px] font-medium tracking-[-0.01em]\" },\n} as const;\n\nconst clamp01 = (n: number) => Math.min(1, Math.max(0, n));\n\nexport type ProgressRingProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Current value. null spins: the work has started but its size isn't known. */\n  value: number | null;\n  min?: number;\n  max?: number;\n  /** 20px (icon size, no number), 40px or 64px. */\n  size?: keyof typeof SIZES;\n  /** The rolling percentage in the center. Defaults to true for md and lg. */\n  showValue?: boolean;\n  /** The work stopped. The arc and the number turn the danger color. */\n  error?: boolean;\n  /** Replace the center, e.g. an icon or \"3/12\". The completion tick still takes over at the end. */\n  children?: React.ReactNode;\n  getAriaValueText?: (formattedValue: string, value: number | null) => string;\n};\n\nexport function ProgressRing({\n  value,\n  min = 0,\n  max = 100,\n  size = \"md\",\n  showValue,\n  error = false,\n  children,\n  getAriaValueText,\n  className,\n  style,\n  ...rest\n}: ProgressRingProps) {\n  const reduce = useReducedMotion();\n  const { px, stroke, text } = SIZES[size];\n  const indeterminate = value === null || !Number.isFinite(value);\n  const pct = indeterminate ? 0 : clamp01(((value as number) - min) / (max - min || 1)) * 100;\n  const complete = !indeterminate && pct >= 100 && !error;\n  const withValue = showValue ?? size !== \"sm\";\n  const r = (px - stroke) / 2;\n  const c = px / 2;\n  const state = error ? \"error\" : complete ? \"complete\" : indeterminate ? \"indeterminate\" : \"progressing\";\n  const arcColor = error ? \"text-danger\" : complete ? \"text-success\" : \"text-fg\";\n\n  return (\n    <Progress.Root\n      value={indeterminate ? null : value}\n      min={min}\n      max={max}\n      getAriaValueText={(formatted, v) => (getAriaValueText ? getAriaValueText(formatted, v) : error ? `${formatted}, failed` : complete ? \"Complete\" : (formatted ?? \"Loading\"))}\n      data-state={state}\n      data-size={size}\n      className={cn(\"stealth-ring relative inline-grid shrink-0 place-items-center align-middle\", className)}\n      style={{ width: px, height: px, ...style }}\n      {...rest}\n    >\n      <style href=\"stealth-progress-ring\" precedence=\"default\">\n        {CSS}\n      </style>\n\n      {/* A small settle when it completes: the ring answers the moment it closes. */}\n      <motion.svg\n        width={px}\n        height={px}\n        viewBox={`0 0 ${px} ${px}`}\n        fill=\"none\"\n        aria-hidden\n        className=\"absolute inset-0 -rotate-90\"\n        animate={complete && !reduce ? { scale: [1, 1.06, 1] } : { scale: 1 }}\n        transition={{ duration: 0.42, ease: ease.out, delay: 0.38 }}\n      >\n        <circle cx={c} cy={c} r={r} stroke=\"currentColor\" strokeWidth={stroke} className=\"text-line-2\" />\n        {indeterminate ? (\n          <circle\n            data-part=\"spin\"\n            cx={c}\n            cy={c}\n            r={r}\n            pathLength={100}\n            stroke=\"currentColor\"\n            strokeWidth={stroke}\n            strokeLinecap=\"round\"\n            strokeDasharray=\"28 72\"\n            className={arcColor}\n          />\n        ) : (\n          <circle\n            cx={c}\n            cy={c}\n            r={r}\n            pathLength={100}\n            stroke=\"currentColor\"\n            strokeWidth={stroke}\n            strokeLinecap=\"round\"\n            strokeDasharray=\"100 100\"\n            // A zero-length arc with round caps would draw a dot; hide it until there is progress.\n            style={{ strokeDashoffset: 100 - pct, opacity: pct > 0 ? 1 : 0 }}\n            className={cn(\"transition-[stroke-dashoffset,color,opacity] duration-[480ms] ease-out-quart motion-reduce:duration-150\", arcColor)}\n          />\n        )}\n      </motion.svg>\n\n      <span className={cn(\"tabular relative grid place-items-center leading-none\", text, error ? \"text-danger\" : \"text-fg\")}>\n        <AnimatePresence initial={false}>\n          {complete ? (\n            <motion.span\n              key=\"done\"\n              className=\"col-start-1 row-start-1 grid place-items-center text-success\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n              transition={reduce ? { duration: 0.15 } : { ...spring.pop, delay: 0.3 }}\n            >\n              <Tick px={px} reduce={!!reduce} />\n            </motion.span>\n          ) : children != null ? (\n            <motion.span key=\"custom\" className=\"col-start-1 row-start-1 grid place-items-center\" exit={{ opacity: 0, scale: 0.8, transition: { duration: 0.12 } }}>\n              {children}\n            </motion.span>\n          ) : withValue && !indeterminate ? (\n            <motion.span\n              key=\"value\"\n              aria-hidden\n              className=\"col-start-1 row-start-1\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}\n              animate={{ opacity: 1, scale: 1 }}\n              // Waits for the arc to close before making way for the tick.\n              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8, filter: \"blur(2px)\", transition: { duration: 0.14, delay: 0.24 } }}\n              transition={{ duration: 0.2, ease: ease.out }}\n            >\n              <NumberFlow value={pct / 100} format={{ style: \"percent\", maximumFractionDigits: 0 }} animated={!reduce} willChange />\n            </motion.span>\n          ) : null}\n        </AnimatePresence>\n      </span>\n    </Progress.Root>\n  );\n}\n\nfunction Tick({ px, reduce }: { px: number; reduce: boolean }) {\n  const s = Math.round(px * 0.45);\n  return (\n    <svg width={s} height={s} viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={px < 32 ? 2 : 1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.34, ease: ease.out, delay: 0.36 }}\n      />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/progress-ring"
}
