{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pull-to-refresh",
  "type": "registry:ui",
  "title": "Pull to Refresh",
  "description": "Pull a scroll container down at the top to refresh: rubber-band resistance, a filling ring, a spinner and a Refresh button.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/pull-to-refresh.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useTransform } from \"motion/react\";\n\ntype Spring = { stiffness: number; damping: number; mass: number };\ntype Status = \"idle\" | \"armed\" | \"refreshing\" | \"done\" | \"error\";\n\nexport interface PullToRefreshProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"children\" | \"title\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"\n  > {\n  children: React.ReactNode;\n  /** Called on release past the threshold or from the Refresh button. The spinner shows until it settles. */\n  onRefresh: () => Promise<unknown> | void;\n  /** Pull distance (px) that arms a refresh. */\n  threshold?: number;\n  /** The pull approaches this distance (px) asymptotically. */\n  maxPull?: number;\n  /** Higher values need a longer drag for the same pull. */\n  resistance?: number;\n  /** Left side of the toolbar, e.g. a heading. */\n  title?: React.ReactNode;\n  /** A visible Refresh button in the toolbar for keyboard and mouse users. */\n  showRefreshButton?: boolean;\n  /** Accessible name of the scroll region. */\n  label?: string;\n  /** Announced and shown after a successful refresh. */\n  updatedText?: string;\n  /** Announced and shown when onRefresh rejects. */\n  errorText?: string;\n  /** Indicator ring and focus colour. */\n  accent?: string;\n  /** Spring for settling and springing back. */\n  spring?: Spring;\n  /** Classes for the scroll container. */\n  scrollClassName?: string;\n}\n\nconst HOLD = 56; // height the content rests at while refreshing\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/**\n * Pull-to-refresh for a scroll container: drag down at the top (touch, pen or mouse) and the content\n * follows with rubber-band resistance while a ring fills and an arrow turns; release past the\n * threshold to run the async onRefresh with a spinner until it settles. A visible Refresh button and\n * live announcements make the same action available without dragging.\n */\nexport function PullToRefresh({\n  children,\n  onRefresh,\n  threshold: thresholdProp = 72,\n  maxPull: maxPullProp = 128,\n  resistance = 1,\n  title,\n  showRefreshButton = true,\n  label = \"Feed\",\n  updatedText = \"Updated\",\n  errorText = \"Couldn't refresh\",\n  accent = \"#FF7448\",\n  spring = { stiffness: 380, damping: 32, mass: 1 },\n  scrollClassName = \"\",\n  className = \"\",\n  style,\n  ...props\n}: PullToRefreshProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const maxPull = Math.max(48, maxPullProp);\n  const threshold = Math.min(Math.max(24, thresholdProp), maxPull * 0.85);\n  const scrollerRef = React.useRef<HTMLDivElement>(null);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const [status, setStatus] = React.useState<Status>(\"idle\");\n  const [message, setMessageRaw] = React.useState(\"\");\n  // Re-announce identical messages by toggling an invisible suffix.\n  const setMessage = React.useCallback((next: string) => setMessageRaw((previous) => (previous === next ? `${next}\\u00a0` : next)), []);\n  const busy = status === \"refreshing\" || status === \"done\" || status === \"error\";\n\n  const y = useMotionValue(0);\n  const progress = useTransform(y, (v) => Math.min(1, v / threshold));\n  const arrowRotate = useTransform(progress, [0, 1], [0, 180]);\n  const indicatorOpacity = useTransform(y, [0, 24], [0, 1]);\n  const indicatorScale = useTransform(progress, [0, 1], [0.7, 1]);\n\n  const gesture = React.useRef({\n    tracking: false,\n    pulling: false,\n    startX: 0,\n    startY: 0,\n    pointerId: -1,\n    armed: false,\n    suppressClick: false,\n  });\n  const mounted = React.useRef(true);\n  const latest = React.useRef({ onRefresh, updatedText, errorText });\n  React.useEffect(() => {\n    latest.current = { onRefresh, updatedText, errorText };\n  });\n  React.useEffect(() => {\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n    };\n  }, []);\n\n  const settle = React.useCallback(\n    (to: number) => (reduceMotion ? animate(y, to, { duration: 0.12 }) : animate(y, to, { type: \"spring\", ...spring })),\n    [reduceMotion, spring, y],\n  );\n\n  const refresh = React.useCallback(async () => {\n    if (busy) return;\n    setStatus(\"refreshing\");\n    setMessage(\"Refreshing\");\n    settle(HOLD);\n    let ok = true;\n    try {\n      await latest.current.onRefresh();\n    } catch {\n      ok = false;\n    }\n    if (!mounted.current) return;\n    setStatus(ok ? \"done\" : \"error\");\n    setMessage(ok ? latest.current.updatedText : latest.current.errorText);\n    // New content fades up into place while the indicator still shows the result.\n    if (ok && !reduceMotion && contentRef.current) {\n      animate(contentRef.current, { opacity: [0.35, 1], y: [10, 0] }, { duration: 0.45, ease: [0.2, 0, 0, 1] });\n    }\n    await new Promise((resolve) => window.setTimeout(resolve, ok ? 650 : 1400));\n    if (!mounted.current) return;\n    await settle(0);\n    if (mounted.current) setStatus(\"idle\");\n  }, [busy, reduceMotion, settle, setMessage]);\n\n  /** Rubber band: raw drag distance → pull, approaching maxPull. */\n  const toPull = React.useCallback(\n    (raw: number) => maxPull * (1 - Math.exp(-Math.max(0, raw) / (maxPull * Math.max(0.2, resistance)))),\n    [maxPull, resistance],\n  );\n\n  const begin = (clientX: number, clientY: number) => {\n    const g = gesture.current;\n    const scroller = scrollerRef.current;\n    g.tracking = !busy && !!scroller && scroller.scrollTop <= 0;\n    g.pulling = false;\n    g.armed = false;\n    g.suppressClick = false;\n    g.startX = clientX;\n    g.startY = clientY;\n  };\n\n  /** Returns true while this gesture is a pull (the caller then blocks native scrolling). */\n  const move = (clientX: number, clientY: number) => {\n    const g = gesture.current;\n    if (!g.tracking) return false;\n    const dx = clientX - g.startX;\n    const dy = clientY - g.startY;\n    if (!g.pulling) {\n      if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return false;\n      // Only a downward, mostly vertical drag at the very top becomes a pull.\n      if (dy <= 0 || Math.abs(dx) > Math.abs(dy) || (scrollerRef.current?.scrollTop ?? 0) > 0) {\n        g.tracking = false;\n        return false;\n      }\n      g.pulling = true;\n      y.stop();\n    }\n    const pull = toPull(dy);\n    y.set(pull);\n    const armed = pull >= threshold;\n    if (armed !== g.armed) {\n      g.armed = armed;\n      setStatus(armed ? \"armed\" : \"idle\");\n    }\n    return true;\n  };\n\n  const end = () => {\n    const g = gesture.current;\n    const wasPulling = g.pulling;\n    g.tracking = false;\n    g.pulling = false;\n    if (!wasPulling) return;\n    g.suppressClick = true;\n    if (g.armed) {\n      g.armed = false;\n      void refresh();\n    } else {\n      setStatus(\"idle\");\n      settle(0);\n    }\n  };\n\n  // Touch needs a non-passive touchmove so a pull can cancel native scrolling / overscroll.\n  const handlers = React.useRef({ begin, move, end });\n  React.useEffect(() => {\n    handlers.current = { begin, move, end };\n  });\n  React.useEffect(() => {\n    const scroller = scrollerRef.current;\n    if (!scroller) return;\n    const onStart = (event: TouchEvent) => {\n      if (event.touches.length !== 1) return;\n      handlers.current.begin(event.touches[0].clientX, event.touches[0].clientY);\n    };\n    const onMove = (event: TouchEvent) => {\n      if (event.touches.length !== 1) return;\n      if (handlers.current.move(event.touches[0].clientX, event.touches[0].clientY) && event.cancelable) event.preventDefault();\n    };\n    const onEnd = () => handlers.current.end();\n    scroller.addEventListener(\"touchstart\", onStart, { passive: true });\n    scroller.addEventListener(\"touchmove\", onMove, { passive: false });\n    scroller.addEventListener(\"touchend\", onEnd);\n    scroller.addEventListener(\"touchcancel\", onEnd);\n    return () => {\n      scroller.removeEventListener(\"touchstart\", onStart);\n      scroller.removeEventListener(\"touchmove\", onMove);\n      scroller.removeEventListener(\"touchend\", onEnd);\n      scroller.removeEventListener(\"touchcancel\", onEnd);\n    };\n  }, []);\n\n  // Mouse and pen drags (touch is handled above).\n  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"touch\" || event.button !== 0) return;\n    gesture.current.pointerId = event.pointerId;\n    begin(event.clientX, event.clientY);\n  };\n  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const g = gesture.current;\n    if (event.pointerType === \"touch\" || g.pointerId !== event.pointerId) return;\n    const wasPulling = g.pulling;\n    if (move(event.clientX, event.clientY) && !wasPulling) {\n      event.currentTarget.setPointerCapture(event.pointerId);\n      window.getSelection()?.removeAllRanges();\n    }\n  };\n  const onPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"touch\" || gesture.current.pointerId !== event.pointerId) return;\n    gesture.current.pointerId = -1;\n    end();\n  };\n\n  const statusText =\n    status === \"refreshing\"\n      ? \"Refreshing…\"\n      : status === \"done\"\n        ? updatedText\n        : status === \"error\"\n          ? errorText\n          : status === \"armed\"\n            ? \"Release to refresh\"\n            : \"Pull to refresh\";\n\n  return (\n    <div\n      {...props}\n      className={`relative flex h-full min-h-0 flex-col overflow-hidden ${className}`}\n      style={{ [\"--ptr-accent\" as string]: accent, ...style }}\n    >\n      {title || showRefreshButton ? (\n        <div className=\"relative z-10 flex h-12 shrink-0 items-center justify-between gap-3 border-b border-zinc-900/[0.07] px-4 dark:border-white/[0.07]\">\n          <div className=\"min-w-0 truncate text-[15px] font-semibold tracking-tight text-zinc-900 dark:text-zinc-50\">{title}</div>\n          {showRefreshButton ? (\n            <button\n              type=\"button\"\n              onClick={() => void refresh()}\n              aria-disabled={busy || undefined}\n              className=\"inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border border-zinc-900/10 px-3 text-[12px] font-medium tracking-tight text-zinc-700 outline-hidden transition-colors hover:bg-zinc-900/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ptr-accent)] aria-disabled:cursor-default aria-disabled:opacity-60 aria-disabled:hover:bg-transparent dark:border-white/10 dark:text-zinc-200 dark:hover:bg-white/[0.07]\"\n            >\n              <motion.svg\n                viewBox=\"0 0 16 16\"\n                fill=\"none\"\n                aria-hidden=\"true\"\n                className=\"size-3.5\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.6\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                animate={status === \"refreshing\" && !reduceMotion ? { rotate: 360 } : { rotate: 0 }}\n                transition={status === \"refreshing\" && !reduceMotion ? { duration: 0.9, ease: \"linear\", repeat: Infinity } : { duration: 0 }}\n              >\n                <path d=\"M13.2 8a5.2 5.2 0 1 1-1.6-3.75\" />\n                <path d=\"M13.3 2.6v2.9h-2.9\" />\n              </motion.svg>\n              Refresh\n            </button>\n          ) : null}\n        </div>\n      ) : null}\n\n      <div className=\"relative min-h-0 flex-1\">\n        {/* Indicator, revealed in the gap above the content (decorative: the live region speaks). */}\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center justify-center gap-1.5 overflow-hidden\"\n          style={{ height: y, opacity: indicatorOpacity }}\n        >\n          <motion.span className=\"relative grid size-7 place-items-center\" style={{ scale: status === \"idle\" || status === \"armed\" ? indicatorScale : 1 }}>\n            <svg viewBox=\"0 0 28 28\" className=\"absolute inset-0 size-full -rotate-90\" fill=\"none\">\n              <circle cx=\"14\" cy=\"14\" r=\"11\" strokeWidth=\"2\" className=\"stroke-zinc-900/10 dark:stroke-white/15\" />\n              {status === \"idle\" || status === \"armed\" ? (\n                <motion.circle cx=\"14\" cy=\"14\" r=\"11\" strokeWidth=\"2\" strokeLinecap=\"round\" stroke=\"var(--ptr-accent)\" style={{ pathLength: progress }} />\n              ) : null}\n            </svg>\n            {status === \"refreshing\" ? (\n              <motion.svg\n                viewBox=\"0 0 28 28\"\n                className=\"absolute inset-0 size-full\"\n                fill=\"none\"\n                animate={reduceMotion ? undefined : { rotate: 360 }}\n                transition={{ duration: 0.8, ease: \"linear\", repeat: Infinity }}\n              >\n                <path d=\"M14 3a11 11 0 0 1 11 11\" stroke=\"var(--ptr-accent)\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n              </motion.svg>\n            ) : null}\n            {status === \"done\" ? (\n              <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"var(--ptr-accent)\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                <motion.path d=\"m3.5 8.4 2.9 2.8 6.1-6.4\" initial={{ pathLength: reduceMotion ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: reduceMotion ? 0 : 0.3 }} />\n              </svg>\n            ) : status === \"error\" ? (\n              <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"#D93D42\" strokeWidth=\"2\" strokeLinecap=\"round\">\n                <path d=\"M8 4.2v4.6M8 11.4v.4\" />\n              </svg>\n            ) : status !== \"refreshing\" ? (\n              <motion.svg\n                viewBox=\"0 0 16 16\"\n                className=\"size-3.5 text-zinc-500 dark:text-zinc-400\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth=\"1.7\"\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                style={{ rotate: arrowRotate }}\n              >\n                <path d=\"M8 3.5v9M4.5 9 8 12.5 11.5 9\" />\n              </motion.svg>\n            ) : null}\n          </motion.span>\n          <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">{statusText}</span>\n        </motion.div>\n\n        <motion.div\n          ref={scrollerRef}\n          role=\"region\"\n          aria-label={label}\n          aria-busy={status === \"refreshing\"}\n          tabIndex={0}\n          className={`relative h-full overflow-y-auto overscroll-y-contain outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ptr-accent)] ${scrollClassName}`}\n          style={{ y }}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerEnd}\n          onPointerCancel={onPointerEnd}\n          onClickCapture={(event) => {\n            // The click that ends a mouse pull is not a click on the content.\n            if (gesture.current.suppressClick) {\n              gesture.current.suppressClick = false;\n              event.preventDefault();\n              event.stopPropagation();\n            }\n          }}\n        >\n          <div ref={contentRef}>{children}</div>\n        </motion.div>\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {message}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/pull-to-refresh"
}
