{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pull-to-refresh","type":"registry:component","title":"Pull to Refresh","description":"Native-feeling pull-to-refresh container with drag resistance, threshold feedback and async refresh handling.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","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// beui.dev/components/motion/pull-to-refresh\n\nimport {\n  AnimatePresence,\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type ReactNode,\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  EASE_IN_OUT,\n  EASE_OUT,\n  SPRING_PANEL,\n  SPRING_SWAP,\n} from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type PullToRefreshStatus =\n  | \"idle\"\n  | \"pulling\"\n  | \"ready\"\n  | \"refreshing\";\n\nexport interface PullToRefreshProps {\n  /** Runs after the user pulls beyond the threshold and releases. */\n  onRefresh: () => void | Promise<void>;\n  children: ReactNode;\n  /** Keeps the indicator active while an externally managed refresh runs. */\n  refreshing?: boolean;\n  disabled?: boolean;\n  /** Resisted pull distance in pixels required to refresh. */\n  threshold?: number;\n  /** Maximum resisted pull distance in pixels. */\n  maxPull?: number;\n  /** Content offset in pixels while refreshing. */\n  holdDistance?: number;\n  pullingLabel?: ReactNode;\n  releaseLabel?: ReactNode;\n  refreshingLabel?: ReactNode;\n  ariaLabel?: string;\n  className?: string;\n  contentClassName?: string;\n  indicatorClassName?: string;\n}\n\ntype Gesture = {\n  active: boolean;\n  startX: number;\n  startY: number;\n  pointerId: number | null;\n};\n\nconst EMPTY_GESTURE: Gesture = {\n  active: false,\n  startX: 0,\n  startY: 0,\n  pointerId: null,\n};\n\n// This character needs a compact repeating rhythm rather than a settling\n// spring: a small orbit and blink that read as activity without feeling busy.\nconst CHARACTER_LOOP = {\n  duration: 0.9,\n  ease: EASE_IN_OUT,\n  repeat: Number.POSITIVE_INFINITY,\n} as const;\nconst CALM_PULSE = {\n  duration: 1.2,\n  ease: EASE_IN_OUT,\n  repeat: Number.POSITIVE_INFINITY,\n} as const;\nconst LABEL_SWAP = { duration: 0.16, ease: EASE_OUT } as const;\n\nfunction resistedDistance(distance: number, maxPull: number) {\n  return maxPull * (1 - Math.exp(-Math.max(0, distance) / maxPull));\n}\n\nfunction RefreshBuddy({\n  progress,\n  status,\n  reduce,\n}: {\n  progress: MotionValue<number>;\n  status: PullToRefreshStatus;\n  reduce: boolean;\n}) {\n  const lift = useTransform(progress, [0, 1], [-7, 0]);\n  const tilt = useTransform(progress, [0, 1], [-10, 0]);\n  const stretch = useTransform(progress, [0, 0.55, 1], [0.68, 1.1, 0.92]);\n  const ready = status === \"ready\";\n  const refreshing = status === \"refreshing\";\n\n  return (\n    <motion.span\n      style={reduce ? undefined : { y: lift, rotate: tilt, scaleY: stretch }}\n      className=\"block h-9 w-9 origin-bottom\"\n    >\n      <motion.svg\n        aria-hidden=\"true\"\n        viewBox=\"0 0 36 36\"\n        style={{ opacity: 1 }}\n        className=\"h-full w-full overflow-visible\"\n        animate={\n          refreshing\n            ? reduce\n              ? { opacity: [0.55, 1, 0.55] }\n              : { y: [0, -2, 0], rotate: [-3, 3, -3] }\n            : reduce\n              ? { opacity: 1 }\n              : { y: 0, rotate: 0, scale: ready ? 1.08 : 1 }\n        }\n        transition={refreshing ? (reduce ? CALM_PULSE : CHARACTER_LOOP) : SPRING_SWAP}\n      >\n        <motion.g\n          style={{ transformOrigin: \"18px 18px\" }}\n          animate={\n            refreshing && !reduce\n              ? { rotate: [0, 360] }\n              : reduce\n                ? undefined\n                : { rotate: ready ? 0 : -35 }\n          }\n          transition={refreshing ? (reduce ? CALM_PULSE : CHARACTER_LOOP) : SPRING_SWAP}\n          className={cn(\n            \"transition-opacity duration-150\",\n            ready || refreshing ? \"opacity-100\" : \"opacity-0\",\n          )}\n        >\n          <path\n            d=\"M18 2.5a15.5 15.5 0 0 1 12.7 6.6\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.5\"\n            strokeLinecap=\"round\"\n            className=\"text-muted-foreground\"\n          />\n          <circle cx=\"31.3\" cy=\"10.2\" r=\"2.2\" className=\"fill-foreground\" />\n        </motion.g>\n\n        <rect\n          x=\"7\"\n          y=\"7\"\n          width=\"22\"\n          height=\"22\"\n          rx=\"9\"\n          className=\"fill-foreground\"\n        />\n\n        <motion.g\n          style={{ opacity: 1, transformOrigin: \"18px 16px\" }}\n          animate={\n            refreshing && !reduce\n              ? { scaleY: [1, 1, 0.15, 1, 1] }\n              : reduce\n                ? { opacity: 1 }\n                : { scaleY: ready ? 1.18 : 1 }\n          }\n          transition={refreshing && !reduce ? CHARACTER_LOOP : SPRING_SWAP}\n        >\n          <circle cx=\"14.2\" cy=\"16\" r=\"1.45\" className=\"fill-background\" />\n          <circle cx=\"21.8\" cy=\"16\" r=\"1.45\" className=\"fill-background\" />\n        </motion.g>\n\n        <path\n          d=\"M14.5 21h7\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          className={cn(\n            \"text-background transition-opacity duration-150\",\n            ready || refreshing ? \"opacity-0\" : \"opacity-100\",\n          )}\n        />\n        <path\n          d=\"M14 20.5c1 2.4 7 2.4 8 0\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.5\"\n          strokeLinecap=\"round\"\n          className={cn(\n            \"text-background transition-opacity duration-150\",\n            ready ? \"opacity-100\" : \"opacity-0\",\n          )}\n        />\n        <circle\n          cx=\"18\"\n          cy=\"21\"\n          r=\"1.6\"\n          className={cn(\n            \"fill-background transition-opacity duration-150\",\n            refreshing ? \"opacity-100\" : \"opacity-0\",\n          )}\n        />\n      </motion.svg>\n    </motion.span>\n  );\n}\n\nexport function PullToRefresh({\n  onRefresh,\n  children,\n  refreshing = false,\n  disabled = false,\n  threshold = 76,\n  maxPull = 132,\n  holdDistance = 68,\n  pullingLabel = \"Pull to refresh\",\n  releaseLabel = \"Release to refresh\",\n  refreshingLabel = \"Refreshing\",\n  ariaLabel = \"Refreshable content\",\n  className,\n  contentClassName,\n  indicatorClassName,\n}: PullToRefreshProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  const gestureRef = useRef<Gesture>({ ...EMPTY_GESTURE });\n  const animationRef = useRef<{ stop: () => void } | null>(null);\n  const statusRef = useRef<PullToRefreshStatus>(\"idle\");\n  const disabledRef = useRef(disabled);\n  const externalRefreshingRef = useRef(refreshing);\n  const refreshingRef = useRef(refreshing);\n  const [status, setStatusState] = useState<PullToRefreshStatus>(\"idle\");\n  const [internalRefreshing, setInternalRefreshing] = useState(false);\n  const reduce = useReducedMotion();\n  const pullThreshold = Math.max(24, threshold);\n  const pullLimit = Math.max(maxPull, pullThreshold + 24);\n  const restingDistance = Math.min(\n    Math.max(0, holdDistance),\n    pullThreshold,\n  );\n  const y = useMotionValue(0);\n  const progress = useTransform(y, [0, pullThreshold], [0, 1]);\n  const indicatorOpacity = useTransform(\n    y,\n    [0, 10, pullThreshold],\n    [0, 0.45, 1],\n  );\n  const indicatorScale = useTransform(y, [0, pullThreshold], [0.86, 1]);\n  const isRefreshing = refreshing || internalRefreshing;\n\n  disabledRef.current = disabled;\n  externalRefreshingRef.current = refreshing;\n  refreshingRef.current = isRefreshing;\n\n  const setStatus = useCallback((next: PullToRefreshStatus) => {\n    if (statusRef.current === next) return;\n    statusRef.current = next;\n    setStatusState(next);\n  }, []);\n\n  const settle = useCallback(\n    (target: number) => {\n      animationRef.current?.stop();\n\n      if (reduce) {\n        y.set(target);\n        return;\n      }\n\n      animationRef.current = animate(y, target, SPRING_PANEL);\n    },\n    [reduce, y],\n  );\n\n  const updatePull = useCallback(\n    (distance: number) => {\n      if (disabledRef.current || refreshingRef.current) return;\n      animationRef.current?.stop();\n\n      const next = resistedDistance(distance, pullLimit);\n      y.set(next);\n      setStatus(next >= pullThreshold ? \"ready\" : \"pulling\");\n    },\n    [pullLimit, pullThreshold, setStatus, y],\n  );\n\n  const runRefresh = useCallback(async () => {\n    if (disabledRef.current || refreshingRef.current) return;\n\n    setInternalRefreshing(true);\n    setStatus(\"refreshing\");\n    settle(restingDistance);\n\n    try {\n      await onRefresh();\n    } finally {\n      setInternalRefreshing(false);\n\n      // A synchronous refresh can resolve before React commits the temporary\n      // internal state, so release here instead of relying only on the effect.\n      if (!externalRefreshingRef.current) {\n        setStatus(\"idle\");\n        settle(0);\n      }\n    }\n  }, [onRefresh, restingDistance, setStatus, settle]);\n\n  const finishPull = useCallback(() => {\n    const shouldRefresh =\n      y.get() >= pullThreshold &&\n      !disabledRef.current &&\n      !refreshingRef.current;\n\n    gestureRef.current = { ...EMPTY_GESTURE };\n\n    if (shouldRefresh) {\n      void runRefresh();\n      return;\n    }\n\n    setStatus(\"idle\");\n    settle(0);\n  }, [pullThreshold, runRefresh, setStatus, settle, y]);\n\n  useEffect(() => {\n    if (isRefreshing) {\n      setStatus(\"refreshing\");\n      settle(restingDistance);\n      return;\n    }\n\n    if (statusRef.current === \"refreshing\") {\n      setStatus(\"idle\");\n      settle(0);\n    }\n  }, [isRefreshing, restingDistance, setStatus, settle]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n\n    const onTouchStart = (event: TouchEvent) => {\n      if (\n        event.touches.length !== 1 ||\n        root.scrollTop > 0 ||\n        disabledRef.current ||\n        refreshingRef.current\n      ) {\n        return;\n      }\n\n      const touch = event.touches[0];\n      gestureRef.current = {\n        active: true,\n        startX: touch.clientX,\n        startY: touch.clientY,\n        pointerId: null,\n      };\n    };\n\n    const onTouchMove = (event: TouchEvent) => {\n      const gesture = gestureRef.current;\n      const touch = event.touches[0];\n      if (!gesture.active || !touch) return;\n\n      const deltaX = touch.clientX - gesture.startX;\n      const deltaY = touch.clientY - gesture.startY;\n\n      if (root.scrollTop > 0 || deltaY < 0) {\n        gestureRef.current = { ...EMPTY_GESTURE };\n        return;\n      }\n\n      if (Math.abs(deltaX) > deltaY) return;\n\n      event.preventDefault();\n      updatePull(deltaY);\n    };\n\n    const onTouchEnd = () => {\n      if (gestureRef.current.active) finishPull();\n    };\n\n    root.addEventListener(\"touchstart\", onTouchStart, { passive: true });\n    root.addEventListener(\"touchmove\", onTouchMove, { passive: false });\n    root.addEventListener(\"touchend\", onTouchEnd);\n    root.addEventListener(\"touchcancel\", onTouchEnd);\n\n    return () => {\n      root.removeEventListener(\"touchstart\", onTouchStart);\n      root.removeEventListener(\"touchmove\", onTouchMove);\n      root.removeEventListener(\"touchend\", onTouchEnd);\n      root.removeEventListener(\"touchcancel\", onTouchEnd);\n    };\n  }, [finishPull, updatePull]);\n\n  useEffect(() => {\n    return () => animationRef.current?.stop();\n  }, []);\n\n  const startMousePull = (event: ReactPointerEvent<HTMLElement>) => {\n    if (\n      event.pointerType !== \"mouse\" ||\n      event.button !== 0 ||\n      event.currentTarget.scrollTop > 0 ||\n      disabled ||\n      isRefreshing\n    ) {\n      return;\n    }\n\n    event.currentTarget.setPointerCapture(event.pointerId);\n    gestureRef.current = {\n      active: true,\n      startX: event.clientX,\n      startY: event.clientY,\n      pointerId: event.pointerId,\n    };\n  };\n\n  const moveMousePull = (event: ReactPointerEvent<HTMLElement>) => {\n    const gesture = gestureRef.current;\n    if (!gesture.active || gesture.pointerId !== event.pointerId) return;\n\n    const deltaX = event.clientX - gesture.startX;\n    const deltaY = event.clientY - gesture.startY;\n    if (deltaY < 0 || Math.abs(deltaX) > deltaY) return;\n\n    event.preventDefault();\n    updatePull(deltaY);\n  };\n\n  const label =\n    status === \"refreshing\"\n      ? refreshingLabel\n      : status === \"ready\"\n        ? releaseLabel\n        : pullingLabel;\n\n  return (\n    <section\n      ref={rootRef}\n      aria-label={ariaLabel}\n      aria-busy={isRefreshing}\n      data-state={status}\n      data-disabled={disabled || undefined}\n      onPointerDown={startMousePull}\n      onPointerMove={moveMousePull}\n      onPointerUp={(event) => {\n        if (gestureRef.current.pointerId === event.pointerId) finishPull();\n      }}\n      onPointerCancel={(event) => {\n        if (gestureRef.current.pointerId === event.pointerId) finishPull();\n      }}\n      className={cn(\n        \"relative w-full overflow-y-auto overscroll-contain bg-background\",\n        status === \"pulling\" || status === \"ready\"\n          ? \"cursor-grabbing select-none\"\n          : \"cursor-grab\",\n        (disabled || isRefreshing) && \"cursor-default\",\n        className,\n      )}\n    >\n      <motion.div\n        aria-live=\"polite\"\n        aria-atomic=\"true\"\n        style={\n          reduce\n            ? { opacity: indicatorOpacity }\n            : { opacity: indicatorOpacity, scale: indicatorScale }\n        }\n        className={cn(\n          \"pointer-events-none absolute inset-x-0 top-0 z-20 flex h-[4.25rem] flex-col items-center justify-center gap-0.5 bg-gradient-to-b from-background via-background/95 to-transparent text-[11px] font-medium text-muted-foreground\",\n          indicatorClassName,\n        )}\n      >\n        <RefreshBuddy\n          progress={progress}\n          status={status}\n          reduce={Boolean(reduce)}\n        />\n        <span className=\"relative h-4 min-w-24 text-center\">\n          <AnimatePresence initial={false} mode=\"wait\">\n            <motion.span\n              key={status}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 3 }}\n              animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0 }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}\n              transition={LABEL_SWAP}\n              className=\"absolute inset-x-0 whitespace-nowrap\"\n            >\n              {label}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      </motion.div>\n\n      <motion.div\n        style={reduce ? undefined : { y }}\n        className={cn(\n          \"relative z-10 min-h-full bg-inherit will-change-transform\",\n          contentClassName,\n        )}\n      >\n        {children}\n      </motion.div>\n    </section>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\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"}]}