{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-pull-to-refresh", "type": "registry:hook", "title": "usePullToRefresh", "description": "Touch pull-to-refresh that coexists with tab swipes and overscroll-behavior: none.", "categories": [ "hooks" ], "registryDependencies": [ "https://whiskeyjack.net/r/announce.json" ], "files": [ { "path": "hooks/use-pull-to-refresh.ts", "type": "registry:hook", "target": "hooks/use-pull-to-refresh.ts", "content": "import * as React from \"react\";\nimport { announce } from \"@/lib/announce\";\n\nexport interface UsePullToRefreshOptions {\n /**\n * Refresh action run when the user pulls past the threshold and releases.\n * The spinner stays visible until the returned promise settles.\n */\n onRefresh: () => void | Promise;\n /**\n * Ref to the scroll container to watch. Omit (or pass a ref whose `current`\n * is null) for apps that scroll the document/window rather than an inner\n * element -- the gesture then reads `window.scrollY`.\n */\n scrollRef?: React.RefObject;\n /** Disable the gesture entirely (e.g. signed out, nothing to refresh). */\n disabled?: boolean;\n /** Pull distance (px, after resistance) past which release triggers a refresh. */\n threshold?: number;\n /** Announced to screen readers when a refresh starts (an i18n string). */\n refreshingLabel?: string;\n /** Announced to screen readers when a refresh finishes (an i18n string). */\n refreshedLabel?: string;\n}\n\nexport interface UsePullToRefreshResult {\n /** Current visual pull distance in px (after resistance); 0 when idle. */\n pullDistance: number;\n /** True while `onRefresh` is in flight. */\n isRefreshing: boolean;\n}\n\nconst DEFAULT_THRESHOLD = 64;\nconst RESISTANCE = 0.5;\n// Match useSwipeNavigation's axis-lock slop so a pull and a horizontal\n// tab-swipe are decided the same way and never fight each other.\nconst AXIS_SLOP = 8;\nconst MAX_PULL = 120;\n\n/**\n * Pull-to-refresh for touch devices. A downward drag that STARTS at the top of\n * the scroll container reveals a refresh affordance; releasing past `threshold`\n * runs `onRefresh`. Opt-in and gesture-driven:\n *\n * - Coexists with the iOS overscroll fix (`overscroll-behavior: none`): that\n * only suppresses the visual bounce, not touch events, and this hook owns the\n * gesture by calling `preventDefault` on a non-passive `touchmove` listener.\n * - Coexists with `useSwipeNavigation`: it locks to one axis after `AXIS_SLOP`\n * and only acts on a downward VERTICAL drag, so horizontal tab-swipes are\n * untouched. It never engages mid-page (only when scrolled to the very top).\n * - Touch-only (desktop keeps its explicit Sync action). Motion is handled by\n * the global `prefers-reduced-motion` stylesheet, so no JS gating is needed.\n *\n * Pair with `PullToRefreshIndicator` for the spinner. Pass i18n strings for the\n * screen-reader announcements; the indicator itself is visual-only.\n */\nexport function usePullToRefresh({\n onRefresh,\n scrollRef,\n disabled = false,\n threshold = DEFAULT_THRESHOLD,\n refreshingLabel,\n refreshedLabel,\n}: UsePullToRefreshOptions): UsePullToRefreshResult {\n const [pullDistance, setPullDistance] = React.useState(0);\n const [isRefreshing, setIsRefreshing] = React.useState(false);\n\n // Refs so the listeners (registered once per effect run) always read fresh\n // values without re-registering on every render.\n const onRefreshRef = React.useRef(onRefresh);\n onRefreshRef.current = onRefresh;\n const pullDistanceRef = React.useRef(0);\n pullDistanceRef.current = pullDistance;\n const isRefreshingRef = React.useRef(false);\n\n React.useEffect(() => {\n if (disabled) return;\n\n const el = scrollRef?.current ?? null;\n const target: HTMLElement | Window = el ?? window;\n const scrollTopOf = () =>\n el ? el.scrollTop : window.scrollY || document.documentElement.scrollTop;\n\n let startY: number | null = null;\n let startX: number | null = null;\n let axis: \"vertical\" | \"horizontal\" | null = null;\n let pulling = false;\n\n const reset = () => {\n startY = null;\n startX = null;\n axis = null;\n pulling = false;\n };\n\n const onTouchStart = (e: TouchEvent) => {\n if (isRefreshingRef.current || e.touches.length !== 1) return;\n if (scrollTopOf() > 0) {\n startY = null;\n return;\n }\n startY = e.touches[0].clientY;\n startX = e.touches[0].clientX;\n axis = null;\n pulling = false;\n };\n\n const onTouchMove = (e: TouchEvent) => {\n if (startY === null || startX === null || isRefreshingRef.current) return;\n const dy = e.touches[0].clientY - startY;\n const dx = e.touches[0].clientX - startX;\n\n if (axis === null && (Math.abs(dx) > AXIS_SLOP || Math.abs(dy) > AXIS_SLOP)) {\n axis = Math.abs(dy) > Math.abs(dx) ? \"vertical\" : \"horizontal\";\n }\n\n // Only a downward vertical drag while pinned to the top is a pull.\n if (axis !== \"vertical\" || dy <= 0 || scrollTopOf() > 0) {\n if (pulling) {\n pulling = false;\n setPullDistance(0);\n }\n return;\n }\n\n pulling = true;\n // We own the gesture now -- stop the browser from scrolling/bouncing.\n if (e.cancelable) e.preventDefault();\n setPullDistance(Math.min(MAX_PULL, dy * RESISTANCE));\n };\n\n const onTouchEnd = () => {\n if (startY === null) return;\n const triggered = pulling && pullDistanceRef.current >= threshold;\n reset();\n if (!triggered || isRefreshingRef.current) {\n setPullDistance(0);\n return;\n }\n isRefreshingRef.current = true;\n setIsRefreshing(true);\n setPullDistance(threshold); // park at threshold while spinning\n if (refreshingLabel) announce(refreshingLabel);\n Promise.resolve(onRefreshRef.current())\n .catch(() => {})\n .finally(() => {\n isRefreshingRef.current = false;\n setIsRefreshing(false);\n setPullDistance(0);\n if (refreshedLabel) announce(refreshedLabel);\n });\n };\n\n target.addEventListener(\"touchstart\", onTouchStart as EventListener, { passive: true });\n target.addEventListener(\"touchmove\", onTouchMove as EventListener, { passive: false });\n target.addEventListener(\"touchend\", onTouchEnd as EventListener);\n target.addEventListener(\"touchcancel\", onTouchEnd as EventListener);\n return () => {\n target.removeEventListener(\"touchstart\", onTouchStart as EventListener);\n target.removeEventListener(\"touchmove\", onTouchMove as EventListener);\n target.removeEventListener(\"touchend\", onTouchEnd as EventListener);\n target.removeEventListener(\"touchcancel\", onTouchEnd as EventListener);\n };\n }, [scrollRef, disabled, threshold, refreshingLabel, refreshedLabel]);\n\n return { pullDistance, isRefreshing };\n}\n" } ], "docs": "Engages only on a downward drag pinned to the top of the scroller. Pass scrollRef for an inner scroller, or omit it for the document. It mirrors useSwipeNavigation's axis lock so horizontal tab swipes are untouched. Gate it with disabled when there is nothing to sync. Pair with PullToRefreshIndicator.", "meta": { "group": "hooks", "related": [ "pull-to-refresh-indicator", "use-swipe-navigation" ], "exports": [ "usePullToRefresh" ], "siteSlug": "use-pull-to-refresh" } }