{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "progress-ring-check",
  "title": "Progress Ring Check",
  "author": "henriquegpb <https://github.com/henriquegpb>",
  "description": "A task status icon whose dashed ring closes into a spinning arc, fills to its progress, then draws a check or a cross and strikes the label through.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/microkit/progress-ring-check.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { animate, useMotionValue, useReducedMotion } from \"motion/react\";\n\nexport type ProgressRingCheckStatus = \"pending\" | \"running\" | \"done\" | \"failed\" | \"cancelled\";\n\nexport type ProgressRingCheckProps = {\n  status?: ProgressRingCheckStatus;\n  progress?: number;\n  label?: ReactNode;\n  color?: string;\n  doneColor?: string;\n  errorColor?: string;\n  size?: number;\n  strokeWidth?: number;\n  dashes?: number;\n  fontSize?: number;\n  spinDuration?: number;\n  arcLength?: number;\n  drawDuration?: number;\n  fillOpacity?: number;\n  strike?: boolean;\n  strikeDelay?: number;\n  className?: string;\n  style?: CSSProperties;\n};\n\nconst UI = { type: \"spring\" as const, duration: 0.3, bounce: 0 };\nconst MORPH = { duration: 0.3, ease: [0.77, 0, 0.175, 1] as [number, number, number, number] };\nconst CHECK = \"M7.5 12.25 10.5 15.25 16.75 8.75\";\nconst CROSS = \"M8.5 8.5 15.5 15.5M15.5 8.5 8.5 15.5\";\nconst TEXT: Record<ProgressRingCheckStatus, string> = {\n  pending: \"Pending\",\n  running: \"In progress\",\n  done: \"Completed\",\n  failed: \"Failed\",\n  cancelled: \"Cancelled\",\n};\nconst IDLE_DASH = 0.3;\nconst STYLE = \"@keyframes prc-breathe{50%{opacity:.45}}\";\n\nconst clamp01 = (value: number) => Math.min(1, Math.max(0, value));\n\nexport function ProgressRingCheck({\n  status = \"pending\",\n  progress,\n  label,\n  color = \"currentColor\",\n  doneColor = \"#22c55e\",\n  errorColor = \"#ef4444\",\n  size = 20,\n  strokeWidth = 2,\n  dashes = 8,\n  fontSize = 14,\n  spinDuration = 1100,\n  arcLength = 0.68,\n  drawDuration = 240,\n  fillOpacity = 0.06,\n  strike = true,\n  strikeDelay = 60,\n  className = \"\",\n  style,\n}: ProgressRingCheckProps) {\n  const reduce = useReducedMotion();\n  const r = 10 - strokeWidth / 2;\n  const C = 2 * Math.PI * r;\n  const P = C / Math.max(1, dashes);\n  const determinate = status === \"running\" && typeof progress === \"number\" && Number.isFinite(progress);\n  const indeterminate = status === \"running\" && !determinate;\n  const solid = status === \"running\" || status === \"done\" || status === \"failed\";\n  const targetArc = indeterminate ? arcLength : determinate ? clamp01(progress as number) : 1;\n\n  const mode = useMotionValue(solid ? 1 : 0);\n  const arc = useMotionValue(targetArc);\n  const travel = useMotionValue(0);\n  const ringRef = useRef<SVGCircleElement>(null);\n  const geo = useRef({ C, P });\n  const gen = useRef(0);\n\n  const writeDash = () => {\n    const g = geo.current;\n    const m = mode.get();\n    const a = arc.get();\n    const dash = IDLE_DASH * g.P + (a * g.C - IDLE_DASH * g.P) * m;\n    const gap = (1 - IDLE_DASH) * g.P + ((1 - a) * g.C - (1 - IDLE_DASH) * g.P) * m;\n    ringRef.current?.setAttribute(\"stroke-dasharray\", `${Math.max(0, dash)} ${Math.max(0, gap)}`);\n  };\n\n  useLayoutEffect(() => {\n    geo.current = { C, P };\n    writeDash();\n    ringRef.current?.setAttribute(\"stroke-dashoffset\", String(travel.get()));\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [C, P]);\n\n  useEffect(() => {\n    const offs = [\n      mode.on(\"change\", writeDash),\n      arc.on(\"change\", writeDash),\n      travel.on(\"change\", (value: number) => ringRef.current?.setAttribute(\"stroke-dashoffset\", String(value))),\n    ];\n    return () => {\n      offs.forEach((off) => off());\n      mode.stop();\n      arc.stop();\n      travel.stop();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  useEffect(() => {\n    const g = ++gen.current;\n    if (reduce) {\n      mode.jump(solid ? 1 : 0);\n      arc.jump(targetArc);\n      travel.jump(0);\n      return;\n    }\n    if (mode.get() === 0) arc.jump(targetArc);\n    animate(mode, solid ? 1 : 0, MORPH);\n    animate(arc, targetArc, UI);\n    if (indeterminate) {\n      const t0 = travel.get();\n      animate(travel, [t0, t0 - C], { duration: spinDuration / 1000, ease: \"linear\", repeat: Infinity });\n      return;\n    }\n    const unit = determinate ? C : P;\n    const to = Math.floor(travel.get() / unit) * unit;\n    animate(travel, to, UI).then(() => {\n      if (gen.current === g) travel.jump(0);\n    });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [status, determinate, targetArc, reduce, C, P, spinDuration]);\n\n  const spoken = TEXT[status] + (determinate ? `, ${Math.round(clamp01(progress as number) * 100)}%` : \"\");\n  const hasLabel = label !== undefined && label !== null;\n\n  return (\n    <span\n      className={`group relative inline-flex items-center align-middle leading-none [gap:calc(var(--prc-size)*0.5)]${className ? ` ${className}` : \"\"}`}\n      data-status={status}\n      data-indeterminate={indeterminate ? \"\" : undefined}\n      data-strike={strike ? \"\" : undefined}\n      style={\n        {\n          \"--prc-size\": `${size}px`,\n          \"--prc-stroke\": strokeWidth,\n          \"--prc-color\": color,\n          \"--prc-done\": doneColor,\n          \"--prc-error\": errorColor,\n          \"--prc-fill\": fillOpacity,\n          \"--prc-font\": `${fontSize}px`,\n          \"--prc-draw\": `${drawDuration}ms`,\n          \"--prc-strike-delay\": `${120 + strikeDelay}ms`,\n          \"--prc-check-delay\": \"120ms\",\n          ...style,\n        } as CSSProperties\n      }\n    >\n      <style>{STYLE}</style>\n      <svg\n        className=\"shrink-0 overflow-visible [color:var(--prc-color)] [transition:color_200ms_ease] group-data-[status=done]:[color:var(--prc-done)] group-data-[status=failed]:[color:var(--prc-error)]\"\n        viewBox=\"0 0 24 24\"\n        width={size}\n        height={size}\n        role={hasLabel ? undefined : \"img\"}\n        aria-label={hasLabel ? undefined : spoken}\n        aria-hidden={hasLabel || undefined}\n      >\n        <circle className=\"[fill:currentColor] [stroke:currentColor] [stroke-width:var(--prc-stroke)] [fill-opacity:0] [stroke-opacity:0] [transition:fill-opacity_180ms_ease,stroke-opacity_200ms_ease] group-data-[status=running]:[stroke-opacity:0.2] group-data-[status=done]:[fill-opacity:var(--prc-fill)] group-data-[status=failed]:[fill-opacity:var(--prc-fill)]\" cx=\"12\" cy=\"12\" r={r} transform=\"rotate(-90 12 12)\" />\n        <circle ref={ringRef} className=\"fill-none [stroke:currentColor] [stroke-width:var(--prc-stroke)] [stroke-linecap:round] opacity-[0.55] [transition:opacity_200ms_ease] group-data-[status=running]:opacity-100 group-data-[status=done]:opacity-100 group-data-[status=failed]:opacity-100 motion-reduce:group-data-[indeterminate]:animate-[prc-breathe_1400ms_cubic-bezier(0.77,0,0.175,1)_infinite]\" cx=\"12\" cy=\"12\" r={r} transform=\"rotate(-90 12 12)\" />\n        <path className=\"fill-none [stroke:currentColor] [stroke-width:var(--prc-stroke)] [stroke-linecap:round] [stroke-linejoin:round] [stroke-dasharray:1_2] [stroke-dashoffset:1.05] opacity-0 [transition:stroke-dashoffset_160ms_cubic-bezier(0.23,1,0.32,1),opacity_0ms_linear_160ms] motion-reduce:[stroke-dashoffset:0] motion-reduce:[transition:opacity_200ms_ease] group-data-[status=done]:[stroke-dashoffset:0] group-data-[status=done]:opacity-100 group-data-[status=done]:[transition:stroke-dashoffset_var(--prc-draw)_cubic-bezier(0.23,1,0.32,1)_var(--prc-check-delay),opacity_0ms_linear_var(--prc-check-delay)] motion-reduce:group-data-[status=done]:[transition:opacity_200ms_ease]\" d={CHECK} pathLength=\"1\" />\n        <path className=\"fill-none [stroke:currentColor] [stroke-width:var(--prc-stroke)] [stroke-linecap:round] [stroke-linejoin:round] [stroke-dasharray:1_2] [stroke-dashoffset:1.05] opacity-0 [transition:stroke-dashoffset_160ms_cubic-bezier(0.23,1,0.32,1),opacity_0ms_linear_160ms] motion-reduce:[stroke-dashoffset:0] motion-reduce:[transition:opacity_200ms_ease] group-data-[status=failed]:[stroke-dashoffset:0] group-data-[status=failed]:opacity-100 group-data-[status=failed]:[transition:stroke-dashoffset_var(--prc-draw)_cubic-bezier(0.23,1,0.32,1)_var(--prc-check-delay),opacity_0ms_linear_var(--prc-check-delay)] motion-reduce:group-data-[status=failed]:[transition:opacity_200ms_ease] group-data-[status=cancelled]:[stroke-dashoffset:0] group-data-[status=cancelled]:opacity-100 group-data-[status=cancelled]:[transition:stroke-dashoffset_var(--prc-draw)_cubic-bezier(0.23,1,0.32,1)_var(--prc-check-delay),opacity_0ms_linear_var(--prc-check-delay)] motion-reduce:group-data-[status=cancelled]:[transition:opacity_200ms_ease]\" d={CROSS} pathLength=\"1\" />\n      </svg>\n      {hasLabel ? <span className=\"sr-only\">{spoken}: </span> : null}\n      {hasLabel ? (\n        <span className=\"relative leading-[1.25] opacity-[0.65] [color:var(--prc-color)] [font-size:var(--prc-font)] [transition:opacity_200ms_ease] group-data-[status=running]:opacity-100 group-data-[status=done]:opacity-60 group-data-[status=failed]:opacity-100 group-data-[status=cancelled]:opacity-[0.55]\">\n          {label}\n          <span className=\"pointer-events-none absolute inset-x-0 top-1/2 origin-left bg-current [transform:scaleX(0)] [translate:0_-50%] [height:max(1px,calc(var(--prc-font)/14))] [transition:transform_160ms_cubic-bezier(0.23,1,0.32,1)] group-data-[status=done]:group-data-[strike]:[transform:scaleX(1)] group-data-[status=done]:group-data-[strike]:[transition:transform_280ms_cubic-bezier(0.23,1,0.32,1)_var(--prc-strike-delay)] motion-reduce:[transform:scaleX(1)]! motion-reduce:opacity-0 motion-reduce:[transition:opacity_200ms_ease] motion-reduce:group-data-[status=done]:group-data-[strike]:opacity-100 motion-reduce:group-data-[status=done]:group-data-[strike]:[transition:opacity_200ms_ease]\" aria-hidden=\"true\" />\n        </span>\n      ) : null}\n    </span>\n  );\n}\n\nconst tasks: { label: string; status: ProgressRingCheckStatus }[] = [\n  { label: \"Sync calendar events\", status: \"done\" },\n  { label: \"Generate weekly report\", status: \"running\" },\n  { label: \"Notify the team\", status: \"pending\" },\n];\n\nconst SPIN_MS = 1600;\n\nconst nextStatus: Record<Exclude<ProgressRingCheckStatus, \"pending\" | \"running\">, ProgressRingCheckStatus> = {\n  done: \"failed\",\n  failed: \"cancelled\",\n  cancelled: \"pending\",\n};\n\nexport function ProgressRingCheckList() {\n  const [statuses, setStatuses] = useState(() => tasks.map((task) => task.status));\n  const timers = useRef<number[]>([]);\n\n  const update = (index: number, status: ProgressRingCheckStatus) =>\n    setStatuses((current) => current.map((value, i) => (i === index ? status : value)));\n\n  const finishLater = (index: number) => {\n    timers.current.push(window.setTimeout(() => update(index, \"done\"), SPIN_MS));\n  };\n\n  useEffect(() => {\n    const pending = timers.current;\n    tasks.forEach((task, index) => {\n      if (task.status !== \"running\") return;\n      const finish = () => setStatuses((current) => current.map((value, i) => (i === index ? \"done\" : value)));\n      pending.push(window.setTimeout(finish, SPIN_MS));\n    });\n    return () => pending.forEach((timer) => window.clearTimeout(timer));\n  }, []);\n\n  const advance = (index: number) => {\n    const status = statuses[index];\n    if (status === \"pending\") {\n      update(index, \"running\");\n      finishLater(index);\n    } else if (status !== \"running\") {\n      update(index, nextStatus[status]);\n    }\n  };\n\n  return (\n    <div className=\"flex flex-col gap-[2px]\">\n      {tasks.map((task, index) => (\n        <button key={task.label} type=\"button\" className=\"flex min-h-[44px] cursor-pointer appearance-none items-center rounded-[12px] border-0 bg-transparent px-[14px] text-left [font-family:inherit] text-[light-dark(#14181e,#f2f4f6)] [transition:background-color_.2s_ease] hover:bg-[light-dark(#f0f2f5,#1b1e23)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#f97316] motion-reduce:transition-none\" onClick={() => advance(index)}>\n          <ProgressRingCheck\n            status={statuses[index]}\n            label={task.label}\n            size={22}\n            fontSize={16}\n          />\n        </button>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/microkit/progress-ring-check.tsx"
    }
  ],
  "docs": "Live preview and the CSS, JavaScript and Tailwind variants: https://microkit.co/components/progress-ring-check",
  "categories": [
    "button",
    "click"
  ],
  "type": "registry:component"
}