{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pull-to-refresh","type":"registry:component","title":"Pull to Refresh","description":"A pull-down gesture over scrollable content: the indicator tracks the finger 1:1, then resists past the trigger distance and spins while refreshing.","author":"Ryan","dependencies":["clsx","lucide-react","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/pull-to-refresh.tsx","type":"registry:component","target":"@components/motion/pull-to-refresh.tsx","content":"\"use client\";\n// easeui.dev/components/motion/pull-to-refresh\n\nimport { RefreshCw } from \"lucide-react\";\nimport { type PointerEvent as ReactPointerEvent, type ReactNode, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE = \"cubic-bezier(0.23, 1, 0.32, 1)\";\nconst SETTLE_MS = 220;\n/** Pull distance, in px, that arms a refresh on release. */\nconst TRIGGER = 64;\n/** How far the indicator can still be dragged past the trigger, resisting more with distance. */\nconst MAX_PULL = 96;\n\nfunction useReducedMotion() {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const update = () => setReduced(query.matches);\n    update();\n    query.addEventListener(\"change\", update);\n    return () => query.removeEventListener(\"change\", update);\n  }, []);\n  return reduced;\n}\n\nexport interface PullToRefreshProps {\n  /** Called on release past the trigger distance. The indicator keeps spinning until it resolves. */\n  onRefresh: () => Promise<void> | void;\n  /** The scrollable content. Give this element a height through className. */\n  children: ReactNode;\n  className?: string;\n}\n\n/**\n * A pull-down-to-refresh gesture over scrollable content: the indicator\n * tracks the finger 1:1, then resists past the trigger distance. Only\n * starts when the content is already scrolled to the top.\n */\nexport function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) {\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const reduceMotion = useReducedMotion();\n  const [drag, setDrag] = useState<{ startY: number } | null>(null);\n  const [pull, setPull] = useState(0);\n  const [refreshing, setRefreshing] = useState(false);\n\n  const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (refreshing || (scrollRef.current?.scrollTop ?? 0) > 0) return;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    setDrag({ startY: event.clientY });\n  };\n\n  const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {\n    if (!drag) return;\n    const raw = event.clientY - drag.startY;\n    if (raw <= 0) {\n      setPull(0);\n      return;\n    }\n    // Tracks 1:1 up to the trigger distance, then resists.\n    const eased = raw <= TRIGGER ? raw : TRIGGER + (raw - TRIGGER) * 0.35;\n    setPull(Math.min(eased, MAX_PULL));\n  };\n\n  const onPointerUp = () => {\n    if (!drag) return;\n    const armed = pull >= TRIGGER;\n    setDrag(null);\n    if (armed) {\n      setPull(TRIGGER);\n      setRefreshing(true);\n      Promise.resolve(onRefresh()).finally(() => {\n        setRefreshing(false);\n        setPull(0);\n      });\n    } else {\n      setPull(0);\n    }\n  };\n\n  const dragging = drag !== null;\n  const armed = pull >= TRIGGER;\n  const transition = dragging || reduceMotion ? \"none\" : `transform ${SETTLE_MS}ms ${EASE}`;\n  const spin = Math.min(pull / TRIGGER, 1) * 180;\n\n  return (\n    <div className={cn(\"relative overflow-hidden rounded-2xl\", className)}>\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-x-0 top-0 flex justify-center pt-3\"\n        style={{ transform: `translateY(${Math.min(pull, TRIGGER) - TRIGGER}px)`, opacity: Math.min(1, pull / 24), transition }}\n      >\n        <span\n          className={cn(\n            \"flex h-8 w-8 items-center justify-center rounded-full bg-background text-muted-foreground shadow-[0_0_0_1px_var(--border-strong)] transition-colors duration-150\",\n            armed && \"text-accent\",\n          )}\n        >\n          <RefreshCw\n            className={cn(\"h-4 w-4\", refreshing && \"animate-spin\")}\n            style={refreshing ? undefined : { transform: `rotate(${spin}deg)`, transition }}\n          />\n        </span>\n      </div>\n      <div\n        ref={scrollRef}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        style={{ transform: `translateY(${pull}px)`, transition }}\n        className=\"overflow-y-auto overscroll-y-contain\"\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}