{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"progress","type":"registry:component","title":"Progress","description":"Progress indicators in three forms: a linear Progress bar whose fill springs smoothly with an optional label and rolling percentage, a ProgressCircle ring drawn around a centered value, and ProgressSteps segments that fill in sequence; indeterminate modes slide or spin, every variant exposes role=\"progressbar\" with value text, and reduced motion swaps movement for an opacity pulse.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/progress.tsx","type":"registry:component","target":"@components/motion/progress.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/progress\n\nimport {\n  motion,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport { type ReactNode, useEffect, useId, useRef } from \"react\";\nimport { NumberTicker } from \"@/components/motion/number-ticker\";\nimport { EASE_IN_OUT, EASE_OUT, SPRING_GLIDE } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ProgressSize = \"sm\" | \"md\" | \"lg\";\n\n// Reduced motion: indeterminate states drop every transform and keep a calm\n// opacity pulse (same rhythm as Loader's reduced variant).\nconst REDUCED_PULSE = {\n  animate: { opacity: [0.35, 0.85, 0.35] },\n  transition: { duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity },\n};\n\nconst clamp01 = (n: number) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0);\n\n/**\n * A spring-smoothed 0..1 fraction. Uses SPRING_GLIDE (critically damped) so\n * the fill never rebounds past the true value. Reduced motion jumps instead.\n */\nfunction useFraction(fraction: number) {\n  const reduce = useReducedMotion();\n  const spring = useSpring(0, SPRING_GLIDE);\n  useEffect(() => {\n    if (reduce) spring.jump(fraction);\n    else spring.set(fraction);\n  }, [fraction, reduce, spring]);\n  return spring;\n}\n\nfunction defaultValueText(percent: number) {\n  return `${percent}%`;\n}\n\ninterface ProgressCommonProps {\n  /** Current value. Omit (or pass `null`) together with `indeterminate`. */\n  value?: number | null;\n  /** Upper bound. Defaults to 100. */\n  max?: number;\n  /** Unknown-duration mode: a sliding segment (bar) or a spinning arc (circle). */\n  indeterminate?: boolean;\n  /** Visible label. When it is not a string, also pass `aria-label`. */\n  label?: ReactNode;\n  /** Show the rolling percentage. */\n  showValue?: boolean;\n  /** Screen-reader value text. Defaults to \"42%\". */\n  getValueText?: (percent: number, value: number, max: number) => string;\n  \"aria-label\"?: string;\n  className?: string;\n}\n\nfunction useProgressState(\n  value: number | null | undefined,\n  max: number,\n  indeterminate: boolean,\n) {\n  const safeMax = max > 0 ? max : 100;\n  const isIndeterminate = indeterminate || value == null;\n  const current = isIndeterminate ? 0 : Math.min(safeMax, Math.max(0, value ?? 0));\n  const fraction = clamp01(current / safeMax);\n  const percent = Math.round(fraction * 100);\n  return { safeMax, isIndeterminate, current, fraction, percent };\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                  Progress                                  */\n/* -------------------------------------------------------------------------- */\n\nconst TRACK_SIZE: Record<ProgressSize, string> = {\n  sm: \"h-1\",\n  md: \"h-2\",\n  lg: \"h-3\",\n};\n\nexport interface ProgressProps extends ProgressCommonProps {\n  size?: ProgressSize;\n  /** Classes for the fill (e.g. `bg-emerald-500`). */\n  indicatorClassName?: string;\n  /** Classes for the track. */\n  trackClassName?: string;\n}\n\n/**\n * Linear progress bar. The fill is an HTML element scaled on X from its left\n * edge (transform only), smoothed by a critically damped spring.\n */\nexport function Progress({\n  value,\n  max = 100,\n  indeterminate = false,\n  label,\n  showValue = false,\n  size = \"md\",\n  getValueText = defaultValueText,\n  indicatorClassName,\n  trackClassName,\n  className,\n  \"aria-label\": ariaLabel,\n}: ProgressProps) {\n  const reduce = useReducedMotion();\n  const labelId = useId();\n  const { safeMax, isIndeterminate, current, fraction, percent } =\n    useProgressState(value, max, indeterminate);\n  const scaleX = useFraction(fraction);\n  const hasHeader = label != null || (showValue && !isIndeterminate);\n\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label={ariaLabel ?? (typeof label === \"string\" ? label : undefined)}\n      aria-labelledby={ariaLabel == null && label != null && typeof label !== \"string\" ? labelId : undefined}\n      aria-valuemin={0}\n      aria-valuemax={safeMax}\n      aria-valuenow={isIndeterminate ? undefined : current}\n      aria-valuetext={\n        isIndeterminate ? undefined : getValueText(percent, current, safeMax)\n      }\n      className={cn(\"flex w-full flex-col gap-2\", className)}\n    >\n      {hasHeader ? (\n        <div className=\"flex items-baseline justify-between gap-3 text-sm\">\n          {label != null ? (\n            <span id={labelId} className=\"min-w-0 truncate font-medium text-foreground\">\n              {label}\n            </span>\n          ) : (\n            <span />\n          )}\n          {showValue && !isIndeterminate ? (\n            <span aria-hidden=\"true\" className=\"shrink-0 text-muted-foreground\">\n              <NumberTicker\n                value={percent}\n                suffix=\"%\"\n                startOnView={false}\n                duration={0.45}\n                stagger={0}\n              />\n            </span>\n          ) : null}\n        </div>\n      ) : null}\n      <div\n        className={cn(\n          \"relative w-full overflow-hidden rounded-full bg-foreground/10\",\n          TRACK_SIZE[size],\n          trackClassName,\n        )}\n      >\n        {isIndeterminate ? (\n          reduce ? (\n            <motion.div\n              className={cn(\"absolute inset-0 rounded-full bg-primary\", indicatorClassName)}\n              animate={REDUCED_PULSE.animate}\n              transition={REDUCED_PULSE.transition}\n            />\n          ) : (\n            // A 40%-wide segment travels from fully off the left edge\n            // (-100% of itself) to fully off the right (250% of itself).\n            <motion.div\n              className={cn(\n                \"absolute inset-y-0 left-0 w-2/5 rounded-full bg-primary\",\n                indicatorClassName,\n              )}\n              initial={{ x: \"-100%\" }}\n              animate={{ x: \"250%\" }}\n              transition={{ duration: 1.4, ease: EASE_IN_OUT, repeat: Infinity }}\n            />\n          )\n        ) : (\n          <motion.div\n            className={cn(\n              \"absolute inset-0 origin-left rounded-full bg-primary\",\n              indicatorClassName,\n            )}\n            style={{ scaleX }}\n          />\n        )}\n      </div>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                               ProgressCircle                               */\n/* -------------------------------------------------------------------------- */\n\nexport interface ProgressCircleProps extends ProgressCommonProps {\n  /** Diameter in px. */\n  size?: number;\n  /** Stroke width in px. */\n  thickness?: number;\n  /** Replace the centered percentage with custom content. */\n  children?: ReactNode;\n  /** Classes for the progress arc (stroke via `text-*`). */\n  indicatorClassName?: string;\n}\n\n/**\n * Ring progress. The arc draws with `pathLength`; the 12 o'clock start and\n * the indeterminate spin rotate whole HTML/SVG boxes with CSS transforms, so\n * no transform-origin is ever set on an inner SVG shape.\n */\nexport function ProgressCircle({\n  value,\n  max = 100,\n  indeterminate = false,\n  label,\n  showValue = true,\n  size = 64,\n  thickness,\n  getValueText = defaultValueText,\n  children,\n  indicatorClassName,\n  className,\n  \"aria-label\": ariaLabel,\n}: ProgressCircleProps) {\n  const reduce = useReducedMotion();\n  const labelId = useId();\n  const { safeMax, isIndeterminate, current, fraction, percent } =\n    useProgressState(value, max, indeterminate);\n  const stroke = thickness ?? Math.max(3, Math.round(size * 0.09));\n  const r = (size - stroke) / 2;\n  const c = size / 2;\n  const pathLength = useFraction(fraction);\n  // A round cap on a zero-length path still paints a dot; hide it at 0.\n  const arcOpacity = useTransform(pathLength, (v) => (v > 0.002 ? 1 : 0));\n  const fontSize = Math.max(10, Math.round(size * 0.22));\n\n  const center =\n    children ??\n    (showValue && !isIndeterminate ? (\n      <NumberTicker\n        value={percent}\n        suffix=\"%\"\n        startOnView={false}\n        duration={0.45}\n        stagger={0}\n        className=\"font-semibold text-foreground\"\n      />\n    ) : null);\n\n  const ring = (\n    <svg\n      width={size}\n      height={size}\n      viewBox={`0 0 ${size} ${size}`}\n      aria-hidden=\"true\"\n      focusable=\"false\"\n      // Start at 12 o'clock: rotate the whole <svg> box with CSS.\n      className=\"-rotate-90\"\n    >\n      <circle\n        cx={c}\n        cy={c}\n        r={r}\n        fill=\"none\"\n        strokeWidth={stroke}\n        className=\"stroke-foreground/10\"\n      />\n      {isIndeterminate ? (\n        <motion.circle\n          cx={c}\n          cy={c}\n          r={r}\n          fill=\"none\"\n          strokeWidth={stroke}\n          strokeLinecap=\"round\"\n          className={cn(\"stroke-current text-primary\", indicatorClassName)}\n          initial={false}\n          animate={{ pathLength: 0.28 }}\n          transition={{ duration: 0 }}\n        />\n      ) : (\n        <motion.circle\n          cx={c}\n          cy={c}\n          r={r}\n          fill=\"none\"\n          strokeWidth={stroke}\n          strokeLinecap=\"round\"\n          className={cn(\"stroke-current text-primary\", indicatorClassName)}\n          style={{ pathLength, opacity: arcOpacity }}\n        />\n      )}\n    </svg>\n  );\n\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label={ariaLabel ?? (typeof label === \"string\" ? label : undefined)}\n      aria-labelledby={ariaLabel == null && label != null && typeof label !== \"string\" ? labelId : undefined}\n      aria-valuemin={0}\n      aria-valuemax={safeMax}\n      aria-valuenow={isIndeterminate ? undefined : current}\n      aria-valuetext={\n        isIndeterminate ? undefined : getValueText(percent, current, safeMax)\n      }\n      className={cn(\"inline-flex flex-col items-center gap-2\", className)}\n    >\n      <div className=\"relative\" style={{ width: size, height: size }}>\n        {isIndeterminate ? (\n          // The spin lives on an HTML wrapper, never on an SVG child.\n          <motion.div\n            className=\"size-full\"\n            animate={reduce ? REDUCED_PULSE.animate : { rotate: 360 }}\n            transition={\n              reduce\n                ? REDUCED_PULSE.transition\n                : { duration: 1, ease: \"linear\", repeat: Infinity }\n            }\n          >\n            {ring}\n          </motion.div>\n        ) : (\n          ring\n        )}\n        {center != null ? (\n          <div\n            aria-hidden=\"true\"\n            className=\"absolute inset-0 flex items-center justify-center tabular-nums\"\n            style={{ fontSize }}\n          >\n            {center}\n          </div>\n        ) : null}\n      </div>\n      {label != null ? (\n        <span id={labelId} className=\"text-xs text-muted-foreground\">\n          {label}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                ProgressSteps                               */\n/* -------------------------------------------------------------------------- */\n\nexport type ProgressStep = { label: string };\n\nexport interface ProgressStepsProps {\n  /** Number of segments, or labelled steps (labels render under segments). */\n  steps: number | ProgressStep[];\n  /**\n   * Completed steps. Fractions partially fill the next segment, e.g. `2.5`\n   * fills two segments and half of the third.\n   */\n  value: number;\n  size?: ProgressSize;\n  /** Hide the step labels even when `steps` carries them. */\n  hideLabels?: boolean;\n  \"aria-label\"?: string;\n  indicatorClassName?: string;\n  className?: string;\n}\n\n// Delay between neighbouring segments so a jump of several steps reads as a\n// sequence (stage 1 completes, then stage 2…) instead of all at once.\nconst SEGMENT_STAGGER = 0.14;\n\n/**\n * Segmented progress: N tracks that fill one after another, e.g. upload\n * stages or onboarding steps. Increases fill left→right; decreases unwind\n * right→left.\n */\nexport function ProgressSteps({\n  steps,\n  value,\n  size = \"md\",\n  hideLabels = false,\n  indicatorClassName,\n  className,\n  \"aria-label\": ariaLabel = \"Progress\",\n}: ProgressStepsProps) {\n  const reduce = useReducedMotion();\n  const list: ProgressStep[] =\n    typeof steps === \"number\"\n      ? Array.from({ length: Math.max(1, steps) }, (_, i) => ({ label: `Step ${i + 1}` }))\n      : steps;\n  const count = list.length;\n  const current = Math.min(count, Math.max(0, Number.isFinite(value) ? value : 0));\n  const showLabels = !hideLabels && typeof steps !== \"number\";\n\n  // Previous value decides the sequence direction and where it starts.\n  const prevRef = useRef(current);\n  const from = prevRef.current;\n  useEffect(() => {\n    prevRef.current = current;\n  }, [current]);\n  const growing = current >= from;\n\n  const activeIndex = Math.min(count - 1, Math.floor(current));\n  const valueText =\n    current >= count\n      ? `All ${count} steps complete`\n      : `Step ${activeIndex + 1} of ${count}: ${list[activeIndex]?.label ?? \"\"}`;\n\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label={ariaLabel}\n      aria-valuemin={0}\n      aria-valuemax={count}\n      aria-valuenow={current}\n      aria-valuetext={valueText}\n      className={cn(\"flex w-full flex-col gap-2\", className)}\n    >\n      <div className=\"flex w-full gap-1.5\">\n        {list.map((_step, i) => {\n          const fill = clamp01(current - i);\n          const order = growing\n            ? Math.max(0, i - Math.floor(from))\n            : Math.max(0, Math.ceil(from) - 1 - i);\n          return (\n            <div\n              // biome-ignore lint/suspicious/noArrayIndexKey: segments are positional and never reorder\n              key={i}\n              className={cn(\n                \"relative flex-1 overflow-hidden rounded-full bg-foreground/10\",\n                TRACK_SIZE[size],\n              )}\n            >\n              <motion.div\n                className={cn(\n                  \"absolute inset-0 origin-left rounded-full bg-primary\",\n                  indicatorClassName,\n                )}\n                initial={false}\n                animate={{ scaleX: fill }}\n                transition={\n                  reduce\n                    ? { duration: 0 }\n                    : { duration: 0.32, ease: EASE_OUT, delay: order * SEGMENT_STAGGER }\n                }\n              />\n            </div>\n          );\n        })}\n      </div>\n      {showLabels ? (\n        <div aria-hidden=\"true\" className=\"flex w-full gap-1.5\">\n          {list.map((step, i) => (\n            <span\n              // biome-ignore lint/suspicious/noArrayIndexKey: segments are positional and never reorder\n              key={i}\n              className={cn(\n                \"min-w-0 flex-1 truncate text-xs transition-colors duration-200\",\n                i < current ? \"text-foreground\" : \"text-muted-foreground\",\n              )}\n            >\n              {step.label}\n            </span>\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"components/motion/number-ticker.tsx","type":"registry:component","target":"@components/motion/number-ticker.tsx","content":"\"use client\";\n\nimport { animate, motion, useInView, useReducedMotion } from \"motion/react\";\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface NumberTickerProps {\n  value: number;\n  /** Digits to pad to (left). */\n  pad?: number;\n  /** Per-digit roll duration in seconds. */\n  duration?: number;\n  /** Stagger between digits. */\n  stagger?: number;\n  /** Render only after the element enters the viewport. */\n  startOnView?: boolean;\n  prefix?: string;\n  suffix?: string;\n  /** Add a small blur during digit rolls. */\n  blur?: boolean;\n  className?: string;\n  digitClassName?: string;\n  /** Insert en-US group separators (commas); fixed so server and client match. Server-component safe. */\n  locale?: boolean;\n  /** Custom formatter. Client-only — server components must use `locale` instead. */\n  format?: (value: number) => string;\n}\n\nconst DIGIT_HEIGHT_EM = 1.1;\nconst DIGITS = Array.from({ length: 10 }, (_, n) => n);\n\nexport function NumberTicker({\n  value,\n  pad,\n  duration = 0.9,\n  stagger = 0.04,\n  startOnView = true,\n  prefix,\n  suffix,\n  blur = false,\n  className,\n  digitClassName,\n  locale,\n  format,\n}: NumberTickerProps) {\n  const containerRef = useRef<HTMLSpanElement>(null);\n  const inView = useInView(containerRef, { once: true, amount: 0.6 });\n  const [armed, setArmed] = useState(!startOnView);\n\n  useEffect(() => {\n    if (startOnView && inView) setArmed(true);\n  }, [startOnView, inView]);\n\n  const text = useMemo(() => {\n    const rounded = Math.round(value);\n    const formatted = format\n      ? format(rounded)\n      : locale\n        ? rounded.toLocaleString(\"en-US\")\n        : rounded.toString();\n    return pad ? formatted.padStart(pad, \"0\") : formatted;\n  }, [value, pad, format, locale]);\n  const glyphs = useMemo(() => {\n    const chars = text.split(\"\");\n    // Key by place value (position from the right): a changing digit keeps its\n    // identity and rolls to the new value instead of remounting and replaying\n    // from 0. Growing numbers add glyphs on the left without re-keying the\n    // ones, tens, hundreds already on screen.\n    return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` }));\n  }, [text]);\n  const readableText = `${prefix ?? \"\"}${text}${suffix ?? \"\"}`;\n\n  // Stagger is an entrance flourish. Once the reveal has played, value\n  // changes roll every digit immediately — a per-digit delay on live updates\n  // reads as lag.\n  const [entered, setEntered] = useState(false);\n  useEffect(() => {\n    if (!armed || entered) return;\n    const total = (duration + glyphs.length * stagger) * 1000;\n    const t = window.setTimeout(() => setEntered(true), total);\n    return () => window.clearTimeout(t);\n  }, [armed, entered, duration, stagger, glyphs.length]);\n\n  return (\n    <span\n      ref={containerRef}\n      className={cn(\"inline-flex items-center tabular-nums\", className)}\n    >\n      <span className=\"sr-only\">{readableText}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-center\">\n        {prefix ? <span>{prefix}</span> : null}\n        {glyphs.map(({ char, id }, i) => {\n          const isDigit = /\\d/.test(char);\n          if (!isDigit) {\n            return (\n              <span key={id} className=\"inline-block\">\n                {char}\n              </span>\n            );\n          }\n          const digit = Number(char);\n          return (\n            <Digit\n              key={id}\n              digit={armed ? digit : 0}\n              delay={entered ? 0 : i * stagger}\n              duration={duration}\n              blur={blur}\n              className={digitClassName}\n            />\n          );\n        })}\n        {suffix ? <span>{suffix}</span> : null}\n      </span>\n    </span>\n  );\n}\n\nfunction Digit({\n  digit,\n  delay,\n  duration,\n  blur,\n  className,\n}: {\n  digit: number;\n  delay: number;\n  duration: number;\n  blur: boolean;\n  className?: string;\n}) {\n  const reduce = useReducedMotion();\n  const columnRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) {\n      return;\n    }\n\n    const node = columnRef.current;\n    const controls = animate(\n      node,\n      { filter: [\"blur(10px)\", \"blur(0px)\"] },\n      {\n        duration: Math.min(duration * 0.75, 0.32),\n        delay,\n        ease: EASE_OUT,\n      },\n    );\n\n    return () => {\n      controls.stop();\n      node.style.filter = \"blur(0px)\";\n    };\n  }, [blur, delay, digit, duration, reduce]);\n\n  return (\n    <span\n      className={cn(\"relative inline-block overflow-hidden\", className)}\n      style={{ height: `${DIGIT_HEIGHT_EM}em`, width: \"1ch\" }}\n    >\n      <motion.span\n        ref={columnRef}\n        initial={{ y: 0 }}\n        animate={{ y: `-${digit * DIGIT_HEIGHT_EM}em` }}\n        transition={\n          reduce\n            ? { duration: 0 }\n            : { duration, delay, ease: EASE_OUT }\n        }\n        className=\"absolute inset-x-0 top-0 flex flex-col items-center will-change-[transform,filter]\"\n      >\n        {DIGITS.map((n) => (\n          <span\n            key={n}\n            className=\"flex h-[1.1em] items-center justify-center leading-none\"\n          >\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}