{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reading-progress",
  "type": "registry:ui",
  "title": "Reading Progress",
  "description": "Tracks the article, not the page, and counts the minutes down.",
  "categories": [
    "scroll"
  ],
  "dependencies": [
    "@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/reading-progress.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\n/** The nearest ancestor that scrolls vertically, or null for the page itself. */\nfunction getScrollParent(el: HTMLElement | null): HTMLElement | null {\n  for (let node = el?.parentElement; node && node !== document.body && node !== document.documentElement; node = node.parentElement) {\n    if (/(auto|scroll|overlay)/.test(getComputedStyle(node).overflowY)) return node;\n  }\n  return null;\n}\n\ntype Target = React.RefObject<HTMLElement | null>;\n\n// The native scroll-driven timeline, where the browser has it. Not in the DOM typings yet.\ntype ViewTimelineCtor = new (options: { subject: Element; axis?: \"block\" | \"inline\" }) => AnimationTimeline;\nconst nativeViewTimeline = () => (typeof window === \"undefined\" ? undefined : (window as unknown as { ViewTimeline?: ViewTimelineCtor }).ViewTimeline);\n\nexport type ReadingProgressOptions = {\n  /** The scroll container. Defaults to the nearest scrolling ancestor of the article, or the page. */\n  scrollRoot?: Target;\n  /** Reading speed used for the estimate. */\n  wordsPerMinute?: number;\n  /** A known reading time, in minutes, instead of counting words. Also lets the label render on the server. */\n  minutes?: number;\n};\n\ntype Visual = { ref: React.RefObject<Element | null>; keyframes: Keyframe[] };\n\n/**\n * Tracks how far through an article the reader is: 0 when its top reaches the\n * top of the scroll container (below any scroll-padding, e.g. a sticky header),\n * 1 when its end reaches the bottom. The drawn indicator is driven by a native\n * view timeline where supported (off the main thread) and by the scroll\n * position otherwise; the number for the label and ARIA is always from script.\n */\nfunction useProgressEngine(target: Target, { scrollRoot, wordsPerMinute = 230, minutes }: ReadingProgressOptions, visual?: Visual) {\n  const [percent, setPercent] = useState(0);\n  const [words, setWords] = useState(0);\n\n  useEffect(() => {\n    const article = target.current;\n    if (!article) return;\n    const scroller = scrollRoot?.current ?? getScrollParent(article);\n    const source: HTMLElement | Window = scroller ?? window;\n    const el = visual?.ref.current;\n    const keyframes = visual?.keyframes ?? [];\n\n    // One paused animation holds the indicator's keyframes. Script scrubs its\n    // currentTime; a native timeline replaces the scrubbing entirely.\n    let anim: Animation | undefined;\n    let native = false;\n    const TL = nativeViewTimeline();\n\n    let padTop = 0;\n    let padBottom = 0;\n    let frame = 0;\n\n    const view = () => {\n      if (scroller) {\n        const r = scroller.getBoundingClientRect();\n        return { top: r.top + scroller.clientTop + padTop, height: scroller.clientHeight - padTop - padBottom };\n      }\n      return { top: padTop, height: window.innerHeight - padTop - padBottom };\n    };\n\n    const measure = () => {\n      const cs = getComputedStyle(scroller ?? document.documentElement);\n      padTop = parseFloat(cs.scrollPaddingTop) || 0;\n      padBottom = parseFloat(cs.scrollPaddingBottom) || 0;\n      if (!minutes) setWords((article.textContent ?? \"\").trim().split(/\\s+/).filter(Boolean).length);\n\n      // A timeline only matches our maths when the article is taller than the view.\n      const tall = article.offsetHeight > view().height;\n      const wantNative = !!TL && !!el && tall;\n      if (el && (!anim || wantNative !== native)) {\n        anim?.cancel();\n        native = wantNative;\n        anim = native\n          ? el.animate(keyframes, { fill: \"both\", timeline: new TL!({ subject: article, axis: \"block\" }), rangeStart: \"contain 0%\", rangeEnd: \"contain 100%\" } as KeyframeAnimationOptions)\n          : el.animate(keyframes, { duration: 1000, fill: \"both\" });\n        if (!native) anim.pause();\n      }\n    };\n\n    const update = () => {\n      frame = 0;\n      const v = view();\n      const r = article.getBoundingClientRect();\n      const span = r.height - v.height;\n      const p = span > 0 ? Math.min(1, Math.max(0, (v.top - r.top) / span)) : r.bottom <= v.top + v.height ? 1 : 0;\n      if (anim && !native) anim.currentTime = p * 1000;\n      // State changes at most 100 times over the whole article.\n      setPercent(Math.round(p * 100));\n    };\n\n    const onScroll = () => {\n      if (!frame) frame = requestAnimationFrame(update);\n    };\n    // Images loading and fonts swapping change the article's height; so do resizes.\n    const ro = new ResizeObserver(() => {\n      measure();\n      onScroll();\n    });\n    ro.observe(article);\n    if (scroller) ro.observe(scroller);\n    source.addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"resize\", onScroll);\n    return () => {\n      ro.disconnect();\n      source.removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"resize\", onScroll);\n      cancelAnimationFrame(frame);\n      anim?.cancel();\n    };\n  }, [target, scrollRoot, minutes, visual?.ref, visual?.keyframes]);\n\n  const total = minutes ?? (words ? Math.max(1, Math.round(words / wordsPerMinute)) : 0);\n  const finished = percent >= 100;\n  const minutesLeft = total ? (finished ? 0 : Math.max(1, Math.ceil(total * (1 - percent / 100)))) : 0;\n  return { percent, totalMinutes: total, minutesLeft, finished };\n}\n\n/** Progress through an article, without any UI: { percent, totalMinutes, minutesLeft, finished }. */\nexport function useReadingProgress(target: Target, options: ReadingProgressOptions = {}) {\n  return useProgressEngine(target, options);\n}\n\nconst valueText = (percent: number, minutesLeft: number, finished: boolean, finishedLabel = \"Finished\") =>\n  finished ? finishedLabel : minutesLeft ? `${percent}%, ${minutesLeft} min left` : `${percent}%`;\n\nconst barKeyframes: Keyframe[] = [{ transform: \"scaleX(0)\" }, { transform: \"scaleX(1)\" }];\nconst ringKeyframes: Keyframe[] = [{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }];\n\nexport type ReadingProgressProps = Omit<React.ComponentProps<\"div\">, \"children\"> &\n  ReadingProgressOptions & {\n    /** The article being read. Progress runs from its top to its end. */\n    target: Target;\n    /** Accessible name. */\n    label?: string;\n  };\n\n/** A hairline bar that fills as the article is read. Position it with className. */\nexport function ReadingProgress({ target, scrollRoot, wordsPerMinute, minutes, label = \"Reading progress\", className, ...rest }: ReadingProgressProps) {\n  const fill = useRef<HTMLDivElement>(null);\n  const { percent, minutesLeft, finished } = useProgressEngine(target, { scrollRoot, wordsPerMinute, minutes }, { ref: fill, keyframes: barKeyframes });\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label={label}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={percent}\n      aria-valuetext={valueText(percent, minutesLeft, finished)}\n      data-state={finished ? \"finished\" : percent > 0 ? \"reading\" : \"idle\"}\n      className={cn(\"pointer-events-none h-0.5 w-full overflow-hidden\", className)}\n      {...rest}\n    >\n      <div ref={fill} style={{ transform: \"scaleX(0)\" }} className=\"size-full origin-left bg-fg rtl:origin-right\" />\n    </div>\n  );\n}\n\nexport type ReadingProgressRingProps = Omit<React.ComponentProps<\"div\">, \"children\"> &\n  ReadingProgressOptions & {\n    target: Target;\n    /** Show \"4 min left\" beside the ring. */\n    showTimeLeft?: boolean;\n    /** Shown in place of the time once the end is reached. */\n    finishedLabel?: string;\n    label?: string;\n  };\n\n/** A small ring with the time left, for a header or a floating pill. Draws a tick at the end. */\nexport function ReadingProgressRing({\n  target,\n  scrollRoot,\n  wordsPerMinute,\n  minutes,\n  showTimeLeft = true,\n  finishedLabel = \"Finished\",\n  label = \"Reading progress\",\n  className,\n  ...rest\n}: ReadingProgressRingProps) {\n  const arc = useRef<SVGCircleElement>(null);\n  const reduce = useReducedMotion();\n  const { percent, minutesLeft, totalMinutes, finished } = useProgressEngine(target, { scrollRoot, wordsPerMinute, minutes }, { ref: arc, keyframes: ringKeyframes });\n\n  return (\n    <div\n      role=\"progressbar\"\n      aria-label={label}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={percent}\n      aria-valuetext={valueText(percent, minutesLeft, finished, finishedLabel)}\n      data-state={finished ? \"finished\" : percent > 0 ? \"reading\" : \"idle\"}\n      className={cn(\"inline-flex items-center gap-2 text-[12px] text-fg-2\", className)}\n      {...rest}\n    >\n      <span className=\"relative grid size-[18px] shrink-0 place-items-center\">\n        <svg viewBox=\"0 0 18 18\" className=\"absolute inset-0 -rotate-90 rtl:scale-x-[-1]\" aria-hidden>\n          <circle cx=\"9\" cy=\"9\" r=\"7.5\" fill=\"none\" strokeWidth=\"1.5\" className=\"stroke-line-2\" />\n          <circle\n            ref={arc}\n            cx=\"9\"\n            cy=\"9\"\n            r=\"7.5\"\n            fill=\"none\"\n            strokeWidth=\"1.5\"\n            strokeLinecap=\"round\"\n            pathLength={1}\n            strokeDasharray=\"1 1\"\n            strokeDashoffset={1}\n            className=\"stroke-fg\"\n          />\n        </svg>\n        <AnimatePresence initial={false}>\n          {finished && (\n            // The ring closes, then fills and a tick draws inside it.\n            <motion.span\n              key=\"done\"\n              className=\"absolute inset-0 grid place-items-center rounded-full bg-fg text-frame\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, transition: { duration: 0.14, ease: ease.in } }}\n              transition={reduce ? { duration: 0.15 } : spring.pop}\n            >\n              <svg width=\"10\" height=\"10\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.2} 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.3, ease: ease.out, delay: 0.08 }}\n                />\n              </svg>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </span>\n\n      {showTimeLeft && (\n        // Both states share one grid cell, so the width never jumps between them.\n        <span aria-hidden className=\"grid whitespace-nowrap text-left\">\n          <span className=\"invisible col-start-1 row-start-1 tabular\">{`${Math.max(totalMinutes, 10)} min left`}</span>\n          <span className=\"invisible col-start-1 row-start-1\">{finishedLabel}</span>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.span\n              key={finished ? \"finished\" : totalMinutes ? \"left\" : \"pending\"}\n              className=\"col-start-1 row-start-1\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(2px)\" }}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: \"blur(2px)\", transition: { duration: 0.14 } }}\n              transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n            >\n              {finished ? (\n                <span className=\"text-fg\">{finishedLabel}</span>\n              ) : totalMinutes ? (\n                <NumberFlow value={minutesLeft} suffix=\" min left\" className=\"tabular\" />\n              ) : null}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/reading-progress"
}
