{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-scroll-progress",
  "title": "useScrollProgress",
  "description": "A render-free vertical scroll progress hook for window or element scrollers with optional time-based smoothing.",
  "files": [
    {
      "path": "registry/base/hooks/use-scroll-progress.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useRef } from \"react\";\nimport type { RefObject } from \"react\";\n\nconst DEFAULT_DISTANCE = 1;\nconst DEFAULT_SMOOTHING = 0.1;\nconst SETTLE_THRESHOLD = 0.0004;\n\nconst useIsomorphicLayoutEffect =\n  typeof window === \"undefined\" ? useEffect : useLayoutEffect;\n\nexport type ScrollProgressSource = \"container\" | \"window\";\n\nexport type UseScrollProgressOptions<\n  TContainer extends HTMLElement,\n  TTrack extends HTMLElement,\n> = {\n  /** Scrollable element. Also used as the measured viewport in container mode. */\n  containerRef: RefObject<TContainer | null>;\n  /** Element whose top edge marks progress zero in window mode. */\n  trackRef?: RefObject<TTrack | null>;\n  /** Read from the element or the page. Defaults to `container`. */\n  source?: ScrollProgressSource;\n  /** Length of the 0–1 range, in measured viewport heights. */\n  distance?: number;\n  /** Exponential follow time in seconds. Set to `0` for direct scrubbing. */\n  smoothing?: number;\n  /** Gate scroll tracking without unmounting the consumer. */\n  enabled?: boolean;\n  /** Value emitted while disabled. Defaults to `1`. */\n  disabledProgress?: number;\n  /** Called outside React's render cycle whenever progress changes. */\n  onProgress: (progress: number) => void;\n  /** Called after the scroll viewport is measured. */\n  onMeasure?: (viewportHeight: number) => void;\n};\n\nexport type UseScrollProgressResult = {\n  /** Re-read viewport size and progress after imperative layout changes. */\n  measure: () => void;\n};\n\n/**\n * Tracks vertical scroll as a clamped 0–1 value without rendering on every\n * frame. It supports a nested scroll container or the page, optional\n * exponential smoothing, resize measurement, and an imperative refresh.\n */\nexport function useScrollProgress<\n  TContainer extends HTMLElement = HTMLElement,\n  TTrack extends HTMLElement = HTMLElement,\n>({\n  containerRef,\n  trackRef,\n  source = \"container\",\n  distance = DEFAULT_DISTANCE,\n  smoothing = DEFAULT_SMOOTHING,\n  enabled = true,\n  disabledProgress = 1,\n  onProgress,\n  onMeasure,\n}: UseScrollProgressOptions<\n  TContainer,\n  TTrack\n>): UseScrollProgressResult {\n  const onProgressRef = useRef(onProgress);\n  const onMeasureRef = useRef(onMeasure);\n  const viewportHeightRef = useRef(1);\n  const viewportWidthRef = useRef<number | null>(null);\n  const initialProgress = clamp(disabledProgress, 0, 1);\n  const currentRef = useRef(initialProgress);\n  const targetRef = useRef(initialProgress);\n  const frameRef = useRef<number | null>(null);\n  const previousFrameTimeRef = useRef<number | null>(null);\n\n  useIsomorphicLayoutEffect(() => {\n    onProgressRef.current = onProgress;\n    onMeasureRef.current = onMeasure;\n  });\n\n  const cancelFrame = useCallback(() => {\n    if (frameRef.current !== null) {\n      cancelAnimationFrame(frameRef.current);\n      frameRef.current = null;\n    }\n\n    previousFrameTimeRef.current = null;\n  }, []);\n\n  const readProgress = useCallback(() => {\n    if (!enabled) {\n      return clamp(disabledProgress, 0, 1);\n    }\n\n    const container = containerRef.current;\n\n    if (!container) {\n      return 0;\n    }\n\n    const scrollSpan =\n      viewportHeightRef.current * Math.max(0.01, finiteNumber(distance, 1));\n\n    if (source === \"window\") {\n      const track = trackRef?.current;\n\n      if (!track) {\n        return 0;\n      }\n\n      return clamp(-track.getBoundingClientRect().top / scrollSpan, 0, 1);\n    }\n\n    return clamp(container.scrollTop / scrollSpan, 0, 1);\n  }, [containerRef, disabledProgress, distance, enabled, source, trackRef]);\n\n  const emitImmediately = useCallback(() => {\n    const progress = readProgress();\n    targetRef.current = progress;\n    currentRef.current = progress;\n    cancelFrame();\n    onProgressRef.current(progress);\n  }, [cancelFrame, readProgress]);\n\n  const readViewportWidth = useCallback(() => {\n    if (source === \"window\") {\n      return window.innerWidth;\n    }\n\n    return containerRef.current?.clientWidth ?? 0;\n  }, [containerRef, source]);\n\n  const measure = useCallback(() => {\n    const container = containerRef.current;\n\n    if (!container) {\n      return;\n    }\n\n    const viewportHeight =\n      source === \"window\" ? window.innerHeight : container.clientHeight;\n\n    if (viewportHeight <= 0) {\n      return;\n    }\n\n    viewportHeightRef.current = viewportHeight;\n    viewportWidthRef.current = readViewportWidth();\n    onMeasureRef.current?.(viewportHeight);\n    emitImmediately();\n  }, [containerRef, emitImmediately, readViewportWidth, source]);\n\n  /**\n   * Mobile browsers resize the viewport as their chrome collapses mid-scroll.\n   * Re-measuring there retimes the track under the gesture and jumps progress,\n   * so height-only changes are ignored while the pointer is coarse.\n   */\n  const measureOnViewportChange = useCallback(() => {\n    if (\n      readViewportWidth() === viewportWidthRef.current &&\n      isCoarsePointer()\n    ) {\n      return;\n    }\n\n    measure();\n  }, [measure, readViewportWidth]);\n\n  useEffect(() => {\n    const container = containerRef.current;\n\n    if (!container) {\n      return;\n    }\n\n    const animate = (now: number) => {\n      const previous = previousFrameTimeRef.current;\n      const deltaSeconds =\n        previous === null ? 1 / 60 : Math.min((now - previous) / 1000, 0.1);\n      previousFrameTimeRef.current = now;\n\n      const followTime = Math.max(0, finiteNumber(smoothing, 0));\n      const blend =\n        followTime === 0 ? 1 : 1 - Math.exp(-deltaSeconds / followTime);\n      const next =\n        currentRef.current + (targetRef.current - currentRef.current) * blend;\n\n      if (Math.abs(targetRef.current - next) <= SETTLE_THRESHOLD) {\n        currentRef.current = targetRef.current;\n        frameRef.current = null;\n        previousFrameTimeRef.current = null;\n        onProgressRef.current(currentRef.current);\n        return;\n      }\n\n      currentRef.current = next;\n      onProgressRef.current(next);\n      frameRef.current = requestAnimationFrame(animate);\n    };\n\n    const handleScroll = () => {\n      targetRef.current = readProgress();\n\n      if (finiteNumber(smoothing, DEFAULT_SMOOTHING) <= 0) {\n        currentRef.current = targetRef.current;\n        onProgressRef.current(currentRef.current);\n        return;\n      }\n\n      if (frameRef.current === null) {\n        frameRef.current = requestAnimationFrame(animate);\n      }\n    };\n\n    measure();\n\n    const scroller: Window | TContainer =\n      source === \"window\" ? window : container;\n\n    if (enabled) {\n      scroller.addEventListener(\"scroll\", handleScroll, { passive: true });\n    }\n\n    window.addEventListener(\"resize\", measureOnViewportChange);\n\n    const resizeObserver =\n      source === \"container\" && typeof ResizeObserver !== \"undefined\"\n        ? new ResizeObserver(measureOnViewportChange)\n        : null;\n\n    resizeObserver?.observe(container);\n\n    return () => {\n      cancelFrame();\n      scroller.removeEventListener(\"scroll\", handleScroll);\n      window.removeEventListener(\"resize\", measureOnViewportChange);\n      resizeObserver?.disconnect();\n    };\n  }, [\n    cancelFrame,\n    containerRef,\n    enabled,\n    measure,\n    measureOnViewportChange,\n    readProgress,\n    smoothing,\n    source,\n  ]);\n\n  return { measure };\n}\n\nfunction isCoarsePointer() {\n  return (\n    typeof window !== \"undefined\" &&\n    typeof window.matchMedia === \"function\" &&\n    window.matchMedia(\"(pointer: coarse)\").matches\n  );\n}\n\nfunction finiteNumber(value: number, fallback: number) {\n  return Number.isFinite(value) ? value : fallback;\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n",
      "type": "registry:hook",
      "target": "@hooks/use-scroll-progress.ts"
    }
  ],
  "meta": {
    "tags": [
      "scroll-progress",
      "request-animation-frame",
      "resize-observer",
      "window-scroll"
    ],
    "effects": [
      "scroll-progress",
      "smoothing"
    ]
  },
  "categories": [
    "motion",
    "scroll"
  ],
  "type": "registry:hook"
}