{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "progress-circular",
  "title": "Progress Circular",
  "description": "Progress in a small square: a ring, a hairline, a 270 degree gauge or discrete cells, with the value in the middle and an honest state when nobody knows.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/progress-circular.tsx",
      "content": "import type * as React from \"react\"\r\nimport { useReducedMotion } from \"motion/react\"\r\nimport { toPercent, VIEW_SIZE } from \"@/registry/miseui/ui/progress-circular-math\"\r\nimport { CircularMarks, type CircularVariant } from \"@/registry/miseui/ui/progress-circular-parts\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\ntype ProgressCircularEffect = \"spring\"\r\ntype ProgressCircularSize = \"sm\" | \"md\" | \"lg\"\r\n\r\nconst DEFAULT_MAX = 100\r\nconst DEFAULT_STEPS = 12\r\nconst MIN_STEPS = 2\r\n\r\nconst SIZE_CLASS: Record<ProgressCircularSize, string> = { sm: \"size-10\", md: \"size-16\", lg: \"size-24\" }\r\nconst VALUE_CLASS: Record<ProgressCircularSize, string> = {\r\n  sm: \"text-[13px]\",\r\n  md: \"text-[13px]\",\r\n  lg: \"text-xl tracking-tight\",\r\n}\r\n\r\ntype ProgressCircularProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\r\n  /** Current value. `null` or omitted means indeterminate: work is happening and no one knows how much. */\r\n  value?: number | null\r\n  max?: number\r\n  /** Names the circle for assistive tech. A large gauge also writes it in its open quarter. */\r\n  label?: string\r\n  /** `ring` thick with round ends, `thin` a hairline, `gauge` 270 degrees like a dial, `segmented` discrete cells. */\r\n  variant?: CircularVariant\r\n  /** `spring` lets the fill settle on a critically damped spring, and a new value interrupts it cleanly. */\r\n  effect?: ProgressCircularEffect\r\n  /** 40, 64 or 96 px. */\r\n  size?: ProgressCircularSize\r\n  /** How many cells `segmented` draws. */\r\n  steps?: number\r\n  /** Writes the percent in the middle. Off by default at `sm`, where it would crowd the ring. */\r\n  showValue?: boolean\r\n  /** Replaces the percent, for an icon or your own unit. */\r\n  children?: React.ReactNode\r\n}\r\n\r\nfunction CentreValue({ percent, size, thin }: { percent: number; size: ProgressCircularSize; thin: boolean }) {\r\n  return (\r\n    <span\r\n      aria-hidden=\"true\"\r\n      data-slot=\"progress-circular-value\"\r\n      className={cn(\"tabular-nums\", thin ? \"font-normal\" : \"font-medium\", VALUE_CLASS[size])}\r\n    >\r\n      {Math.round(percent)}\r\n      <span className=\"text-[0.75em] text-muted-foreground\">%</span>\r\n    </span>\r\n  )\r\n}\r\n\r\nfunction stateOf(percent: number | null) {\r\n  if (percent === null) return \"indeterminate\"\r\n  return percent >= 100 ? \"complete\" : \"loading\"\r\n}\r\n\r\nfunction ProgressCircular({\r\n  value,\r\n  max = DEFAULT_MAX,\r\n  label,\r\n  variant = \"ring\",\r\n  effect,\r\n  size = \"md\",\r\n  steps = DEFAULT_STEPS,\r\n  showValue = size !== \"sm\",\r\n  children,\r\n  className,\r\n  ...props\r\n}: ProgressCircularProps) {\r\n  const reduceMotion = useReducedMotion() ?? false\r\n  const percent = toPercent(value, max)\r\n  const determinate = percent !== null\r\n  const valueText = showValue && determinate ? <CentreValue percent={percent} size={size} thin={variant === \"thin\"} /> : null\r\n  return (\r\n    <div\r\n      role=\"progressbar\"\r\n      aria-label={label}\r\n      aria-valuemin={0}\r\n      aria-valuemax={max}\r\n      aria-valuenow={determinate ? (percent / 100) * max : undefined}\r\n      aria-valuetext={determinate ? `${Math.round(percent)}%` : undefined}\r\n      data-slot=\"progress-circular\"\r\n      data-variant={variant}\r\n      data-effect={effect}\r\n      data-size={size}\r\n      data-state={stateOf(percent)}\r\n      className={cn(\"relative inline-grid shrink-0 place-items-center\", SIZE_CLASS[size], className)}\r\n      {...props}\r\n    >\r\n      <svg aria-hidden=\"true\" viewBox={`0 0 ${VIEW_SIZE} ${VIEW_SIZE}`} className=\"absolute inset-0 size-full\">\r\n        <CircularMarks variant={variant} percent={percent} steps={Math.max(MIN_STEPS, Math.floor(steps))} spring={effect === \"spring\"} reduceMotion={reduceMotion} />\r\n      </svg>\r\n      {children ?? valueText}\r\n      {variant === \"gauge\" && size === \"lg\" && label ? (\r\n        <span aria-hidden=\"true\" data-slot=\"progress-circular-label\" className=\"absolute bottom-0 max-w-[64%] truncate text-[13px] leading-4 text-muted-foreground\">\r\n          {label}\r\n        </span>\r\n      ) : null}\r\n    </div>\r\n  )\r\n}\r\n\r\nexport {\r\n  ProgressCircular,\r\n  type ProgressCircularProps,\r\n  type CircularVariant as ProgressCircularVariant,\r\n  type ProgressCircularEffect,\r\n  type ProgressCircularSize,\r\n}\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/progress-circular-parts.tsx",
      "content": "import { motion } from \"motion/react\"\r\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\r\nimport { arcDash, circumferenceOf, litCells, VIEW_SIZE } from \"@/registry/miseui/ui/progress-circular-math\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\nconst CENTER = VIEW_SIZE / 2\r\nconst TOP_ANGLE = -90\r\n/** The gauge starts at 7:30 and ends at 4:30, leaving the bottom quarter open. */\r\nconst GAUGE_START_ANGLE = 135\r\nconst GAUGE_SPAN = 0.75\r\nconst INDETERMINATE_ARC = 0.25\r\nconst CELL_GAP = 5\r\nconst SPIN_CLASS = \"animate-spin origin-center [animation-duration:1100ms] [transform-box:view-box]\"\r\nconst SWEEP_SECONDS = 1.6\r\nconst CELL_PULSE_SECONDS = 1.2\r\nconst REDUCED_PULSE_SECONDS = 2.4\r\nconst TRACK_CLASS = \"stroke-input/70\"\r\nconst FILL_CLASS = \"stroke-mise\"\r\nconst TIMED_FILL_CLASS = \"duration-(--duration-base) ease-(--ease-mise) motion-reduce:transition-none\"\r\n\r\ntype CircularVariant = \"ring\" | \"thin\" | \"gauge\" | \"segmented\"\r\n\r\nconst STROKE_WIDTH: Record<CircularVariant, number> = { ring: 8, thin: 2.5, gauge: 8, segmented: 8 }\r\n\r\ninterface CircleProps {\r\n  strokeWidth: number\r\n  className?: string\r\n  strokeLinecap?: \"round\" | \"butt\"\r\n  transform?: string\r\n  strokeDasharray?: string\r\n}\r\n\r\nfunction baseCircle({ strokeWidth, strokeLinecap = \"round\", ...rest }: CircleProps) {\r\n  return {\r\n    cx: CENTER,\r\n    cy: CENTER,\r\n    r: (VIEW_SIZE - strokeWidth) / 2,\r\n    fill: \"none\",\r\n    strokeWidth,\r\n    strokeLinecap,\r\n    ...rest,\r\n  }\r\n}\r\n\r\ninterface MarkProps {\r\n  variant: CircularVariant\r\n  percent: number | null\r\n  steps: number\r\n  spring: boolean\r\n  reduceMotion: boolean\r\n}\r\n\r\nfunction ArcTrack({ variant }: { variant: CircularVariant }) {\r\n  const strokeWidth = STROKE_WIDTH[variant]\r\n  const circumference = circumferenceOf(strokeWidth)\r\n  if (variant !== \"gauge\") return <circle {...baseCircle({ strokeWidth, className: TRACK_CLASS })} />\r\n  return (\r\n    <circle\r\n      {...baseCircle({\r\n        strokeWidth,\r\n        className: TRACK_CLASS,\r\n        strokeDasharray: arcDash(GAUGE_SPAN, circumference),\r\n        transform: `rotate(${GAUGE_START_ANGLE} ${CENTER} ${CENTER})`,\r\n      })}\r\n    />\r\n  )\r\n}\r\n\r\nfunction ArcFill({ variant, percent, spring, reduceMotion }: Omit<MarkProps, \"percent\" | \"steps\"> & { percent: number }) {\r\n  const strokeWidth = STROKE_WIDTH[variant]\r\n  const circumference = circumferenceOf(strokeWidth)\r\n  const span = variant === \"gauge\" ? GAUGE_SPAN : 1\r\n  const dash = arcDash((percent / 100) * span, circumference)\r\n  const angle = variant === \"gauge\" ? GAUGE_START_ANGLE : TOP_ANGLE\r\n  const shared = baseCircle({ strokeWidth, transform: `rotate(${angle} ${CENTER} ${CENTER})` })\r\n  // A zero-length dash with round caps still paints a dot, so an empty arc is hidden.\r\n  const opacity = percent > 0 ? 1 : 0\r\n  if (spring && !reduceMotion) {\r\n    return (\r\n      <motion.circle\r\n        {...shared}\r\n        data-slot=\"progress-circular-fill\"\r\n        className={FILL_CLASS}\r\n        initial={false}\r\n        animate={{ strokeDasharray: dash, opacity }}\r\n        transition={SPRING_DEFAULT}\r\n      />\r\n    )\r\n  }\r\n  return (\r\n    <circle\r\n      {...shared}\r\n      data-slot=\"progress-circular-fill\"\r\n      className={cn(FILL_CLASS, \"transition-[stroke-dasharray]\", TIMED_FILL_CLASS)}\r\n      style={{ strokeDasharray: dash, opacity }}\r\n    />\r\n  )\r\n}\r\n\r\nfunction Cells({ percent, steps, spring, reduceMotion }: Omit<MarkProps, \"variant\">) {\r\n  const strokeWidth = STROKE_WIDTH.segmented\r\n  const circumference = circumferenceOf(strokeWidth)\r\n  const cellLength = Math.max(1, circumference / steps - CELL_GAP)\r\n  const lit = percent === null ? 0 : litCells(percent, steps)\r\n  return Array.from({ length: steps }, (_, index) => {\r\n    const angle = TOP_ANGLE + (index * 360) / steps + (CELL_GAP / 2 / circumference) * 360\r\n    const shared = baseCircle({\r\n      strokeWidth,\r\n      strokeLinecap: \"butt\",\r\n      strokeDasharray: `${cellLength} ${circumference}`,\r\n      transform: `rotate(${angle} ${CENTER} ${CENTER})`,\r\n    })\r\n    const on = index < lit\r\n    return (\r\n      <g key={index} data-slot=\"progress-circular-cell\" data-on={on ? \"true\" : undefined}>\r\n        <circle {...shared} className={TRACK_CLASS} />\r\n        {percent === null ? (\r\n          <motion.circle\r\n            {...shared}\r\n            className={FILL_CLASS}\r\n            initial={{ opacity: 0 }}\r\n            animate={reduceMotion ? { opacity: index % 3 === 0 ? 0.6 : 0 } : { opacity: [0, 1, 0] }}\r\n            transition={\r\n              reduceMotion\r\n                ? { duration: 0 }\r\n                : { duration: CELL_PULSE_SECONDS, repeat: Infinity, delay: (index * CELL_PULSE_SECONDS) / steps }\r\n            }\r\n          />\r\n        ) : spring && !reduceMotion ? (\r\n          <motion.circle {...shared} className={FILL_CLASS} initial={false} animate={{ opacity: on ? 1 : 0 }} transition={SPRING_DEFAULT} />\r\n        ) : (\r\n          <circle {...shared} className={cn(FILL_CLASS, \"transition-opacity\", TIMED_FILL_CLASS)} style={{ opacity: on ? 1 : 0 }} />\r\n        )}\r\n      </g>\r\n    )\r\n  })\r\n}\r\n\r\nfunction IndeterminateArc({ variant, reduceMotion }: Pick<MarkProps, \"variant\" | \"reduceMotion\">) {\r\n  const strokeWidth = STROKE_WIDTH[variant]\r\n  const circumference = circumferenceOf(strokeWidth)\r\n  const dash = arcDash(INDETERMINATE_ARC * (variant === \"gauge\" ? GAUGE_SPAN : 1), circumference)\r\n  const angle = variant === \"gauge\" ? GAUGE_START_ANGLE : TOP_ANGLE\r\n  const shared = baseCircle({ strokeWidth, strokeDasharray: dash, transform: `rotate(${angle} ${CENTER} ${CENTER})`, className: FILL_CLASS })\r\n  if (reduceMotion) {\r\n    return (\r\n      <motion.g\r\n        data-slot=\"progress-circular-pulse\"\r\n        animate={{ opacity: [1, 0.4, 1] }}\r\n        transition={{ duration: REDUCED_PULSE_SECONDS, ease: \"easeInOut\", repeat: Infinity }}\r\n      >\r\n        <circle {...shared} />\r\n      </motion.g>\r\n    )\r\n  }\r\n  if (variant === \"gauge\") {\r\n    // A full turn would cross the open quarter, so the arc runs along the gauge and back.\r\n    const travel = GAUGE_SPAN * (1 - INDETERMINATE_ARC) * circumference\r\n    return (\r\n      <motion.circle\r\n        {...shared}\r\n        data-slot=\"progress-circular-sweep\"\r\n        animate={{ strokeDashoffset: [0, -travel] }}\r\n        transition={{ duration: SWEEP_SECONDS, ease: \"easeInOut\", repeat: Infinity, repeatType: \"mirror\" }}\r\n      />\r\n    )\r\n  }\r\n  return (\r\n    <g data-slot=\"progress-circular-spin\" className={SPIN_CLASS}>\r\n      <circle {...shared} />\r\n    </g>\r\n  )\r\n}\r\n\r\nfunction CircularMarks({ variant, percent, steps, spring, reduceMotion }: MarkProps) {\r\n  if (variant === \"segmented\") return <Cells percent={percent} steps={steps} spring={spring} reduceMotion={reduceMotion} />\r\n  return (\r\n    <>\r\n      <ArcTrack variant={variant} />\r\n      {percent === null ? (\r\n        <IndeterminateArc variant={variant} reduceMotion={reduceMotion} />\r\n      ) : (\r\n        <ArcFill variant={variant} percent={percent} spring={spring} reduceMotion={reduceMotion} />\r\n      )}\r\n    </>\r\n  )\r\n}\r\n\r\nexport { CircularMarks, type CircularVariant }\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/progress-circular-math.ts",
      "content": "const VIEW_SIZE = 100\n\n/** Null means nobody knows how far along the work is, so `NaN` from a bad division lands there too. */\nfunction toPercent(value: number | null | undefined, max: number) {\n  if (value === null || value === undefined || Number.isNaN(value) || max <= 0) return null\n  return (Math.min(max, Math.max(0, value)) / max) * 100\n}\n\nfunction circumferenceOf(strokeWidth: number) {\n  return 2 * Math.PI * ((VIEW_SIZE - strokeWidth) / 2)\n}\n\n/** The dash starts at the path start, because an offset dash loses its round cap at that end. */\nfunction arcDash(fraction: number, circumference: number) {\n  return `${fraction * circumference} ${circumference * 2}`\n}\n\n/** Any work shows, and only finished work fills every cell, so a rounded 99% never looks done. */\nfunction litCells(percent: number, steps: number) {\n  if (percent <= 0) return 0\n  if (percent >= 100) return steps\n  return Math.min(steps - 1, Math.max(1, Math.round((percent / 100) * steps)))\n}\n\nexport { arcDash, circumferenceOf, litCells, toPercent, VIEW_SIZE }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "feedback"
  ],
  "type": "registry:ui"
}