{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pull-to-refresh",
  "type": "registry:ui",
  "title": "Pull to Refresh",
  "description": "Rubber-band pull with a filling ring, a flipping arrow and a sprung return.",
  "categories": [
    "async"
  ],
  "dependencies": [
    "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/pull-to-refresh.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useImperativeHandle, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type PullToRefreshPhase = \"idle\" | \"pulling\" | \"armed\" | \"refreshing\" | \"done\" | \"error\";\ntype Phase = PullToRefreshPhase;\n\nexport type PullToRefreshActions = {\n  /** Run a refresh exactly as a pull would: the button equivalent of the gesture. */\n  refresh: () => void;\n};\n\nexport type PullToRefreshProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  children: React.ReactNode;\n  /** Called on release past the threshold. Return a promise; the spinner holds until it settles. */\n  onRefresh: () => unknown;\n  /** How far the content must travel before a release refreshes. */\n  threshold?: number;\n  /** The most the content can travel, however far the finger goes. */\n  maxPull?: number;\n  disabled?: boolean;\n  /** Call refresh() from a button, a shortcut or a focus event. */\n  actionsRef?: React.Ref<PullToRefreshActions>;\n  /** Labels for each moment, in order: pulling, armed, refreshing, done, failed. */\n  labels?: Partial<Record<Exclude<Phase, \"idle\">, string>>;\n  /** Classes for the inner scroll container. */\n  scrollClassName?: string;\n};\n\nconst defaults: Record<Exclude<Phase, \"idle\">, string> = {\n  pulling: \"Pull to refresh\",\n  armed: \"Release to refresh\",\n  refreshing: \"Refreshing…\",\n  done: \"Up to date\",\n  error: \"Couldn’t refresh\",\n};\n\nconst HOLD = 52;\nconst ENGAGE = 6;\n\nexport function PullToRefresh({\n  children,\n  onRefresh,\n  threshold = 64,\n  maxPull = 128,\n  disabled = false,\n  actionsRef,\n  labels,\n  scrollClassName,\n  className,\n  onClickCapture,\n  ...rest\n}: PullToRefreshProps) {\n  const reduce = useReducedMotion();\n  const text = { ...defaults, ...labels };\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const phaseRef = useRef<Phase>(\"idle\");\n  const scroller = useRef<HTMLDivElement>(null);\n  const y = useMotionValue(0);\n  const anim = useRef<AnimationPlaybackControls>(null);\n  const alive = useRef(true);\n  const dragged = useRef(false);\n\n  // The indicator rides in the gap the content leaves, centered in it, and\n  // fades in over the first 24px so a tiny tug doesn't flash it.\n  const pillY = useTransform(y, (v) => v / 2 - 16);\n  const pillOpacity = useTransform(y, [0, 24], [0, 1]);\n  const progress = useTransform(y, [0, threshold], [0, 1], { clamp: true });\n\n  const go = (next: Phase) => {\n    phaseRef.current = next;\n    setPhase(next);\n  };\n  const settle = (to: number) => {\n    anim.current?.stop();\n    anim.current = animate(y, to, reduce ? { duration: 0.15, ease: ease.out } : to === 0 ? spring.sheet : spring.snappy);\n  };\n\n  // Rubber band: the first pixels follow the finger almost 1:1, then each\n  // extra pixel buys less, approaching maxPull and never passing it.\n  const band = (dy: number) => maxPull * (1 - Math.exp(-Math.max(0, dy) / (maxPull * 1.1)));\n\n  const refresh = async () => {\n    const p = phaseRef.current;\n    if (disabled || p === \"refreshing\" || p === \"done\" || p === \"error\") return;\n    const el = scroller.current;\n    if (el && el.scrollTop > 0) el.scrollTo({ top: 0, behavior: reduce ? \"auto\" : \"smooth\" });\n    go(\"refreshing\");\n    settle(HOLD);\n    const started = performance.now();\n    let ok = true;\n    try {\n      await onRefresh();\n    } catch {\n      ok = false;\n    }\n    // A spinner that vanishes after 80ms reads as a glitch; hold it long enough to be seen.\n    const rest = 500 - (performance.now() - started);\n    if (rest > 0) await new Promise((r) => window.setTimeout(r, rest));\n    if (!alive.current) return;\n    go(ok ? \"done\" : \"error\");\n    await new Promise((r) => window.setTimeout(r, ok ? 700 : 1600));\n    if (!alive.current) return;\n    go(\"idle\");\n    settle(0);\n  };\n  const refreshRef = useRef(refresh);\n  useEffect(() => {\n    refreshRef.current = refresh;\n  });\n  useImperativeHandle(actionsRef, () => ({ refresh: () => refreshRef.current() }), []);\n\n  const busy = () => [\"refreshing\", \"done\", \"error\"].includes(phaseRef.current);\n\n  // Shared by touch and mouse: start only at the very top, engage after a few\n  // pixels downward, then follow the finger through the rubber band.\n  const gesture = useRef<{ start: number; engaged: boolean } | null>(null);\n  const begin = (clientY: number) => {\n    if (disabled || busy() || (scroller.current?.scrollTop ?? 0) > 0) return false;\n    gesture.current = { start: clientY, engaged: false };\n    return true;\n  };\n  const move = (clientY: number) => {\n    const g = gesture.current;\n    if (!g) return false;\n    const dy = clientY - g.start;\n    if (!g.engaged) {\n      if (dy < 0) {\n        gesture.current = null;\n        return false;\n      }\n      if (dy < ENGAGE) return false;\n      g.engaged = true;\n      g.start = clientY - ENGAGE;\n      anim.current?.stop();\n    }\n    const pull = band(clientY - g.start);\n    y.set(pull);\n    const next = pull >= threshold ? \"armed\" : \"pulling\";\n    if (phaseRef.current !== next) go(next);\n    return true;\n  };\n  const end = () => {\n    const g = gesture.current;\n    gesture.current = null;\n    if (!g?.engaged) return;\n    if (phaseRef.current === \"armed\") refreshRef.current();\n    else {\n      go(\"idle\");\n      settle(0);\n    }\n  };\n\n  const beginRef = useRef(begin);\n  const moveRef = useRef(move);\n  const endRef = useRef(end);\n  useEffect(() => {\n    beginRef.current = begin;\n    moveRef.current = move;\n    endRef.current = end;\n  });\n\n  // Touch goes through native listeners: touchmove must be non-passive so the\n  // page doesn't scroll (or run the browser's own refresh) while we pull.\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const onStart = (e: TouchEvent) => {\n      if (e.touches.length === 1) beginRef.current(e.touches[0].clientY);\n    };\n    const onMove = (e: TouchEvent) => {\n      if (e.touches.length === 1 && moveRef.current(e.touches[0].clientY) && e.cancelable) e.preventDefault();\n    };\n    const onEnd = () => endRef.current();\n    el.addEventListener(\"touchstart\", onStart, { passive: true });\n    el.addEventListener(\"touchmove\", onMove, { passive: false });\n    el.addEventListener(\"touchend\", onEnd);\n    el.addEventListener(\"touchcancel\", onEnd);\n    return () => {\n      el.removeEventListener(\"touchstart\", onStart);\n      el.removeEventListener(\"touchmove\", onMove);\n      el.removeEventListener(\"touchend\", onEnd);\n      el.removeEventListener(\"touchcancel\", onEnd);\n    };\n  }, []);\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n      anim.current?.stop();\n    };\n  }, []);\n\n  const shown = phase === \"idle\" ? \"pulling\" : phase;\n  const armed = phase === \"armed\" || phase === \"refreshing\";\n\n  return (\n    <div\n      data-state={phase}\n      aria-busy={phase === \"refreshing\" || undefined}\n      className={cn(\"relative isolate overflow-hidden\", phase !== \"idle\" && \"select-none\", className)}\n      onClickCapture={(e) => {\n        onClickCapture?.(e);\n        // A mouse pull ends with a click on whatever was under the pointer; swallow it.\n        if (dragged.current) {\n          dragged.current = false;\n          e.preventDefault();\n          e.stopPropagation();\n        }\n      }}\n      {...rest}\n    >\n      <motion.div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-0 top-0 z-[1] flex justify-center\"\n        style={{ y: pillY, opacity: pillOpacity }}\n      >\n        <span className=\"inline-flex h-8 items-center gap-2 rounded-full border border-line-2 bg-raised pl-2 pr-3 text-[12px] font-medium text-fg-2 shadow-[var(--shadow)]\">\n          <span className=\"relative grid size-4 place-items-center\">\n            <AnimatePresence initial={false}>\n              <motion.span\n                key={shown === \"pulling\" || shown === \"armed\" ? \"arrow\" : shown}\n                className=\"absolute inset-0 grid place-items-center\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, transition: { duration: 0.1 } }}\n                transition={reduce ? { duration: 0.12 } : spring.pop}\n              >\n                {shown === \"refreshing\" ? (\n                  <Spinner />\n                ) : shown === \"done\" ? (\n                  <Tick reduce={!!reduce} />\n                ) : shown === \"error\" ? (\n                  <Cross reduce={!!reduce} />\n                ) : (\n                  <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-4\" aria-hidden>\n                    <circle cx=\"8\" cy=\"8\" r=\"6.75\" stroke=\"var(--line-2)\" strokeWidth=\"1.4\" />\n                    {/* The ring fills as the pull approaches the point of no return. */}\n                    <motion.path d=\"M8 1.25a6.75 6.75 0 1 1 0 13.5a6.75 6.75 0 1 1 0-13.5\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" style={{ pathLength: progress }} />\n                    <motion.path\n                      d=\"M8 5v6M5.75 8.75 8 11l2.25-2.25\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"1.4\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      animate={{ rotate: armed ? 180 : 0 }}\n                      transition={reduce ? { duration: 0 } : spring.snappy}\n                    />\n                  </svg>\n                )}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n          <span className=\"grid\">\n            {Object.values(text).map((l) => (\n              <span key={l} className=\"invisible col-start-1 row-start-1\">\n                {l}\n              </span>\n            ))}\n            <AnimatePresence initial={false}>\n              <motion.span\n                key={shown}\n                className={cn(\"col-start-1 row-start-1\", shown === \"error\" ? \"text-danger\" : shown === \"armed\" || shown === \"done\" ? \"text-fg\" : \"\")}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, transition: { duration: 0.1 } }}\n                transition={{ duration: 0.18, ease: ease.out }}\n              >\n                {text[shown]}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n        </span>\n      </motion.div>\n\n      <div\n        ref={scroller}\n        className={cn(\"h-full overflow-y-auto overscroll-y-contain\", scrollClassName)}\n        onPointerDown={(e) => {\n          // Touch is handled natively above; this is for mouse and pen.\n          if (e.pointerType === \"touch\" || e.button !== 0) return;\n          dragged.current = false;\n          begin(e.clientY);\n        }}\n        onPointerMove={(e) => {\n          if (e.pointerType === \"touch\") return;\n          if (move(e.clientY)) {\n            // Capture only once it is really a pull, so plain clicks still land on their targets.\n            if (!e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.setPointerCapture(e.pointerId);\n            dragged.current = true;\n            e.preventDefault();\n          }\n        }}\n        // Links and images start a native drag on mouse move, which would cancel the pull.\n        onDragStart={(e) => {\n          if (gesture.current) e.preventDefault();\n        }}\n        onPointerUp={(e) => {\n          if (e.pointerType !== \"touch\") end();\n        }}\n        onPointerCancel={(e) => {\n          if (e.pointerType !== \"touch\") end();\n        }}\n      >\n        <motion.div style={{ y }}>{children}</motion.div>\n      </div>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {phase === \"refreshing\" ? text.refreshing : phase === \"done\" ? text.done : phase === \"error\" ? text.error : \"\"}\n      </span>\n    </div>\n  );\n}\n\nconst svg = { viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.4, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true, className: \"size-4\" };\n\nfunction Spinner() {\n  return (\n    <svg {...svg} className=\"size-4 animate-spin [animation-duration:0.8s]\">\n      <circle cx=\"8\" cy=\"8\" r=\"6.75\" stroke=\"var(--line-2)\" />\n      <path d=\"M8 1.25A6.75 6.75 0 0 1 14.75 8\" />\n    </svg>\n  );\n}\n\nfunction Tick({ reduce }: { reduce: boolean }) {\n  return (\n    <svg {...svg} className=\"size-4 text-success\">\n      <circle cx=\"8\" cy=\"8\" r=\"6.75\" />\n      <motion.path d=\"m5.25 8.25 1.9 1.9 3.6-4.1\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.3, ease: ease.out, delay: 0.05 }} />\n    </svg>\n  );\n}\n\nfunction Cross({ reduce }: { reduce: boolean }) {\n  const draw = (delay: number) => (reduce ? {} : { initial: { pathLength: 0 }, animate: { pathLength: 1 }, transition: { duration: 0.24, ease: ease.out, delay } });\n  return (\n    <svg {...svg} className=\"size-4 text-danger\">\n      <circle cx=\"8\" cy=\"8\" r=\"6.75\" />\n      <motion.path d=\"m6 6 4 4\" {...draw(0.05)} />\n      <motion.path d=\"m10 6-4 4\" {...draw(0.14)} />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/pull-to-refresh"
}
