"use client"; import { ReactNode, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; interface PullToRefreshProps { children: ReactNode; } const THRESHOLD = 80; // px of pull distance to trigger refresh const MAX_PULL = 140; // px before rubberbanding clamps const REFRESH_INDICATOR_HEIGHT = 56; /** * Custom pull-to-refresh — no third-party library. * * Only engages when the page is scrolled to the top (`window.scrollY === 0`). * Uses rubberband resistance so the pull feels heavy past a comfortable * range, and clamps the visible offset at MAX_PULL. The release animation * snaps back to 0 (or to REFRESH_INDICATOR_HEIGHT if a refresh is in * flight). Calls `router.refresh()` so the server component re-renders * with fresh data. * * Respects `prefers-reduced-motion: reduce` — the transition is disabled * and the offset still updates, but the snap-back doesn't animate. The * indicator spinner animation is also dropped under reduced motion. */ export function PullToRefresh({ children }: PullToRefreshProps) { const router = useRouter(); const startY = useRef(null); const pulling = useRef(false); const [offset, setOffset] = useState(0); const [refreshing, setRefreshing] = useState(false); const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); useEffect(() => { const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); setPrefersReducedMotion(mq.matches); const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches); mq.addEventListener("change", handler); return () => mq.removeEventListener("change", handler); }, []); function onTouchStart(e: React.TouchEvent) { if (refreshing) return; // Only start a pull if the page is scrolled to the top — pulling // mid-scroll would hijack normal scroll. if (window.scrollY > 0) return; startY.current = e.touches[0].clientY; pulling.current = true; } function onTouchMove(e: React.TouchEvent) { if (!pulling.current || startY.current === null) return; const dy = e.touches[0].clientY - startY.current; if (dy <= 0) { setOffset(0); return; } // Rubberband: resistance curve so it gets harder to pull further. const resistance = 1 - Math.min(1, dy / 400); const next = Math.min(MAX_PULL, dy * resistance); setOffset(next); } async function onTouchEnd() { if (!pulling.current) return; pulling.current = false; startY.current = null; if (offset >= THRESHOLD) { setRefreshing(true); setOffset(REFRESH_INDICATOR_HEIGHT); router.refresh(); // Give the refresh a moment to complete visually. The actual data // fetch happens on the server; this is just the "spinner shown // for a beat" feel. await new Promise((r) => setTimeout(r, 600)); setRefreshing(false); setOffset(0); } else { setOffset(0); } } return (
{offset > 0 && ( ); } export default PullToRefresh;