{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"reading-progress","type":"registry:ui","title":"Reading Progress","description":"How much is left.","dependencies":["motion"],"categories":["scroll"],"docs":"https://www.interior.dev/docs/reading-progress","files":[{"path":"registry/interior/reading-progress.tsx","type":"registry:ui","target":"components/interior/reading-progress.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst FILL = { type: \"spring\", stiffness: 210, damping: 34, mass: 0.9 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst DRAW = { duration: 0.3, ease: EASE, delay: 0.08 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nexport type ScrollRef = { readonly current: HTMLElement | null };\n\nexport type UseReadingProgressOptions = {\n  target?: ScrollRef;\n  scroller?: ScrollRef;\n  steps?: number;\n  words?: number;\n  wordsPerMinute?: number;\n};\n\nexport type ReadingProgressState = {\n  step: number;\n  steps: number;\n  progress: number;\n  percent: number;\n  minutesLeft: number;\n  totalMinutes: number;\n  complete: boolean;\n};\n\nfunction clamp01(n: number) {\n  if (!Number.isFinite(n) || n < 0) return 0;\n  return n > 1 ? 1 : n;\n}\n\nexport function useReadingProgress({\n  target,\n  scroller,\n  steps = 24,\n  words = 0,\n  wordsPerMinute = 220,\n}: UseReadingProgressOptions = {}): ReadingProgressState {\n  const [step, setStep] = useState(0);\n  const frame = useRef(0);\n\n  const read = useCallback(() => {\n    frame.current = 0;\n\n    const scrollEl = scroller?.current ?? null;\n    const targetEl = target?.current ?? null;\n    const viewport = scrollEl ? scrollEl.clientHeight : window.innerHeight;\n\n    let ratio: number;\n    if (targetEl) {\n      const rect = targetEl.getBoundingClientRect();\n      const base = scrollEl ? scrollEl.getBoundingClientRect().top : 0;\n      const travel = rect.height - viewport;\n      ratio = travel <= 0 ? 1 : (base - rect.top) / travel;\n    } else if (scrollEl) {\n      const travel = scrollEl.scrollHeight - scrollEl.clientHeight;\n      ratio = travel <= 0 ? 1 : scrollEl.scrollTop / travel;\n    } else {\n      const doc = document.documentElement;\n      const travel = doc.scrollHeight - viewport;\n      ratio = travel <= 0 ? 1 : window.scrollY / travel;\n    }\n\n    const next = Math.round(clamp01(ratio) * steps);\n    setStep((prev) => (prev === next ? prev : next));\n  }, [scroller, target, steps]);\n\n  useEffect(() => {\n    const scrollEl = scroller?.current ?? null;\n    const targetEl = target?.current ?? null;\n    const source: EventTarget = scrollEl ?? window;\n\n    const schedule = () => {\n      if (frame.current) return;\n      frame.current = requestAnimationFrame(read);\n    };\n\n    source.addEventListener(\"scroll\", schedule, { passive: true });\n    window.addEventListener(\"resize\", schedule);\n\n    const observer =\n      typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(schedule);\n    if (observer) {\n      if (targetEl) observer.observe(targetEl);\n      if (scrollEl) observer.observe(scrollEl);\n      if (!targetEl && !scrollEl) observer.observe(document.documentElement);\n    }\n\n    read();\n\n    return () => {\n      source.removeEventListener(\"scroll\", schedule);\n      window.removeEventListener(\"resize\", schedule);\n      observer?.disconnect();\n      if (frame.current) cancelAnimationFrame(frame.current);\n      frame.current = 0;\n    };\n  }, [read, scroller, target]);\n\n  const progress = steps > 0 ? step / steps : 1;\n  const totalMinutes = words > 0 ? Math.max(1, Math.ceil(words / wordsPerMinute)) : 0;\n  const minutesLeft =\n    words > 0 ? Math.ceil(((1 - progress) * words) / wordsPerMinute) : 0;\n\n  return {\n    step,\n    steps,\n    progress,\n    percent: Math.round(progress * 100),\n    minutesLeft,\n    totalMinutes,\n    complete: step >= steps,\n  };\n}\n\nexport type ReadingProgressProps = UseReadingProgressOptions & {\n  label?: string;\n  doneLabel?: string;\n  className?: string;\n};\n\nexport function ReadingProgress({\n  target,\n  scroller,\n  steps = 24,\n  words = 0,\n  wordsPerMinute = 220,\n  label = \"Reading progress\",\n  doneLabel = \"End\",\n  className = \"\",\n}: ReadingProgressProps) {\n  const { step, percent, minutesLeft, totalMinutes, complete } =\n    useReadingProgress({ target, scroller, steps, words, wordsPerMinute });\n\n  const reduced = useReducedMotion();\n  const fillTransition = reduced ? INSTANT : FILL;\n  const fadeTransition = reduced ? INSTANT : CROSSFADE;\n\n  const estimate = words > 0;\n  const readout = `${minutesLeft} min left`;\n  const finish = `${doneLabel} · ${totalMinutes} min`;\n  const valueText = estimate\n    ? `${percent}% read, ${readout}`\n    : `${percent}% read`;\n\n  return (\n    <div className={`flex items-center gap-3 ${className}`}>\n      <div\n        role=\"progressbar\"\n        aria-label={label}\n        aria-valuemin={0}\n        aria-valuemax={steps}\n        aria-valuenow={step}\n        aria-valuetext={valueText}\n        className=\"min-w-0 flex-1 rounded-[4px] bg-stone-100 p-[2px] shadow-[inset_0_1px_2px_rgba(28,25,23,0.07)] dark:bg-[#1D1D1A] dark:shadow-[inset_0_1px_2px_rgba(0,0,0,0.45)]\"\n      >\n        <motion.div\n          className=\"h-[3px] origin-left rounded-[2px] bg-[#4568FF] dark:bg-[#93B0FF]\"\n          style={{ width: \"100%\" }}\n          initial={false}\n          animate={{ scaleX: step / Math.max(1, steps) }}\n          transition={fillTransition}\n        />\n      </div>\n\n      {estimate ? (\n        <div className=\"grid shrink-0 justify-items-end font-mono text-[10.5px] tabular-nums\">\n          <span\n            aria-hidden\n            className=\"invisible col-start-1 row-start-1 flex items-center gap-1 whitespace-nowrap\"\n          >\n            <span className=\"w-3 shrink-0\" />\n            {finish}\n          </span>\n          <motion.span\n            aria-hidden\n            className=\"col-start-1 row-start-1 whitespace-nowrap text-stone-500 dark:text-stone-400\"\n            initial={false}\n            animate={{ opacity: complete ? 0 : 1 }}\n            transition={fadeTransition}\n          >\n            {readout}\n          </motion.span>\n          <motion.span\n            aria-hidden\n            className=\"col-start-1 row-start-1 flex items-center gap-1 whitespace-nowrap text-stone-700 dark:text-stone-200\"\n            initial={false}\n            animate={{ opacity: complete ? 1 : 0 }}\n            transition={fadeTransition}\n          >\n            <svg\n              width=\"12\"\n              height=\"12\"\n              viewBox=\"0 0 256 256\"\n              fill=\"none\"\n              aria-hidden=\"true\"\n            >\n              <motion.polyline\n                points=\"216 72 104 184 48 128\"\n                stroke=\"currentColor\"\n                strokeWidth=\"16\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={false}\n                animate={{ pathLength: complete ? 1 : 0 }}\n                transition={reduced ? INSTANT : DRAW}\n              />\n            </svg>\n            <motion.span\n              initial={false}\n              animate={{ x: complete ? 0 : 4 }}\n              transition={fadeTransition}\n            >\n              {finish}\n            </motion.span>\n          </motion.span>\n        </div>\n      ) : null}\n    </div>\n  );\n}\n"}]}