"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
type Spring = { stiffness: number; damping: number; mass: number };
type Status = "idle" | "armed" | "refreshing" | "done" | "error";
export interface PullToRefreshProps
extends Omit<
React.ComponentPropsWithoutRef<"div">,
"children" | "title" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd"
> {
children: React.ReactNode;
/** Called on release past the threshold or from the Refresh button. The spinner shows until it settles. */
onRefresh: () => Promise<unknown> | void;
/** Pull distance (px) that arms a refresh. */
threshold?: number;
/** The pull approaches this distance (px) asymptotically. */
maxPull?: number;
/** Higher values need a longer drag for the same pull. */
resistance?: number;
/** Left side of the toolbar, e.g. a heading. */
title?: React.ReactNode;
/** A visible Refresh button in the toolbar for keyboard and mouse users. */
showRefreshButton?: boolean;
/** Accessible name of the scroll region. */
label?: string;
/** Announced and shown after a successful refresh. */
updatedText?: string;
/** Announced and shown when onRefresh rejects. */
errorText?: string;
/** Indicator ring and focus colour. */
accent?: string;
/** Spring for settling and springing back. */
spring?: Spring;
/** Classes for the scroll container. */
scrollClassName?: string;
}
const HOLD = 56; // height the content rests at while refreshing
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/**
* Pull-to-refresh for a scroll container: drag down at the top (touch, pen or mouse) and the content
* follows with rubber-band resistance while a ring fills and an arrow turns; release past the
* threshold to run the async onRefresh with a spinner until it settles. A visible Refresh button and
* live announcements make the same action available without dragging.
*/
export function PullToRefresh({
children,
onRefresh,
threshold: thresholdProp = 72,
maxPull: maxPullProp = 128,
resistance = 1,
title,
showRefreshButton = true,
label = "Feed",
updatedText = "Updated",
errorText = "Couldn't refresh",
accent = "#FF7448",
spring = { stiffness: 380, damping: 32, mass: 1 },
scrollClassName = "",
className = "",
style,
...props
}: PullToRefreshProps) {
const reduceMotion = usePrefersReducedMotion();
const maxPull = Math.max(48, maxPullProp);
const threshold = Math.min(Math.max(24, thresholdProp), maxPull * 0.85);
const scrollerRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const [status, setStatus] = React.useState<Status>("idle");
const [message, setMessageRaw] = React.useState("");
// Re-announce identical messages by toggling an invisible suffix.
const setMessage = React.useCallback((next: string) => setMessageRaw((previous) => (previous === next ? `${next}\u00a0` : next)), []);
const busy = status === "refreshing" || status === "done" || status === "error";
const y = useMotionValue(0);
const progress = useTransform(y, (v) => Math.min(1, v / threshold));
const arrowRotate = useTransform(progress, [0, 1], [0, 180]);
const indicatorOpacity = useTransform(y, [0, 24], [0, 1]);
const indicatorScale = useTransform(progress, [0, 1], [0.7, 1]);
const gesture = React.useRef({
tracking: false,
pulling: false,
startX: 0,
startY: 0,
pointerId: -1,
armed: false,
suppressClick: false,
});
const mounted = React.useRef(true);
const latest = React.useRef({ onRefresh, updatedText, errorText });
React.useEffect(() => {
latest.current = { onRefresh, updatedText, errorText };
});
React.useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const settle = React.useCallback(
(to: number) => (reduceMotion ? animate(y, to, { duration: 0.12 }) : animate(y, to, { type: "spring", ...spring })),
[reduceMotion, spring, y],
);
const refresh = React.useCallback(async () => {
if (busy) return;
setStatus("refreshing");
setMessage("Refreshing");
settle(HOLD);
let ok = true;
try {
await latest.current.onRefresh();
} catch {
ok = false;
}
if (!mounted.current) return;
setStatus(ok ? "done" : "error");
setMessage(ok ? latest.current.updatedText : latest.current.errorText);
// New content fades up into place while the indicator still shows the result.
if (ok && !reduceMotion && contentRef.current) {
animate(contentRef.current, { opacity: [0.35, 1], y: [10, 0] }, { duration: 0.45, ease: [0.2, 0, 0, 1] });
}
await new Promise((resolve) => window.setTimeout(resolve, ok ? 650 : 1400));
if (!mounted.current) return;
await settle(0);
if (mounted.current) setStatus("idle");
}, [busy, reduceMotion, settle, setMessage]);
/** Rubber band: raw drag distance → pull, approaching maxPull. */
const toPull = React.useCallback(
(raw: number) => maxPull * (1 - Math.exp(-Math.max(0, raw) / (maxPull * Math.max(0.2, resistance)))),
[maxPull, resistance],
);
const begin = (clientX: number, clientY: number) => {
const g = gesture.current;
const scroller = scrollerRef.current;
g.tracking = !busy && !!scroller && scroller.scrollTop <= 0;
g.pulling = false;
g.armed = false;
g.suppressClick = false;
g.startX = clientX;
g.startY = clientY;
};
/** Returns true while this gesture is a pull (the caller then blocks native scrolling). */
const move = (clientX: number, clientY: number) => {
const g = gesture.current;
if (!g.tracking) return false;
const dx = clientX - g.startX;
const dy = clientY - g.startY;
if (!g.pulling) {
if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return false;
// Only a downward, mostly vertical drag at the very top becomes a pull.
if (dy <= 0 || Math.abs(dx) > Math.abs(dy) || (scrollerRef.current?.scrollTop ?? 0) > 0) {
g.tracking = false;
return false;
}
g.pulling = true;
y.stop();
}
const pull = toPull(dy);
y.set(pull);
const armed = pull >= threshold;
if (armed !== g.armed) {
g.armed = armed;
setStatus(armed ? "armed" : "idle");
}
return true;
};
const end = () => {
const g = gesture.current;
const wasPulling = g.pulling;
g.tracking = false;
g.pulling = false;
if (!wasPulling) return;
g.suppressClick = true;
if (g.armed) {
g.armed = false;
void refresh();
} else {
setStatus("idle");
settle(0);
}
};
// Touch needs a non-passive touchmove so a pull can cancel native scrolling / overscroll.
const handlers = React.useRef({ begin, move, end });
React.useEffect(() => {
handlers.current = { begin, move, end };
});
React.useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
const onStart = (event: TouchEvent) => {
if (event.touches.length !== 1) return;
handlers.current.begin(event.touches[0].clientX, event.touches[0].clientY);
};
const onMove = (event: TouchEvent) => {
if (event.touches.length !== 1) return;
if (handlers.current.move(event.touches[0].clientX, event.touches[0].clientY) && event.cancelable) event.preventDefault();
};
const onEnd = () => handlers.current.end();
scroller.addEventListener("touchstart", onStart, { passive: true });
scroller.addEventListener("touchmove", onMove, { passive: false });
scroller.addEventListener("touchend", onEnd);
scroller.addEventListener("touchcancel", onEnd);
return () => {
scroller.removeEventListener("touchstart", onStart);
scroller.removeEventListener("touchmove", onMove);
scroller.removeEventListener("touchend", onEnd);
scroller.removeEventListener("touchcancel", onEnd);
};
}, []);
// Mouse and pen drags (touch is handled above).
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "touch" || event.button !== 0) return;
gesture.current.pointerId = event.pointerId;
begin(event.clientX, event.clientY);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const g = gesture.current;
if (event.pointerType === "touch" || g.pointerId !== event.pointerId) return;
const wasPulling = g.pulling;
if (move(event.clientX, event.clientY) && !wasPulling) {
event.currentTarget.setPointerCapture(event.pointerId);
window.getSelection()?.removeAllRanges();
}
};
const onPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "touch" || gesture.current.pointerId !== event.pointerId) return;
gesture.current.pointerId = -1;
end();
};
const statusText =
status === "refreshing"
? "Refreshing…"
: status === "done"
? updatedText
: status === "error"
? errorText
: status === "armed"
? "Release to refresh"
: "Pull to refresh";
return (
<div
{...props}
className={`relative flex h-full min-h-0 flex-col overflow-hidden ${className}`}
style={{ ["--ptr-accent" as string]: accent, ...style }}
>
{title || showRefreshButton ? (
<div className="relative z-10 flex h-12 shrink-0 items-center justify-between gap-3 border-b border-zinc-900/[0.07] px-4 dark:border-white/[0.07]">
<div className="min-w-0 truncate text-[15px] font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">{title}</div>
{showRefreshButton ? (
<button
type="button"
onClick={() => void refresh()}
aria-disabled={busy || undefined}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-full border border-zinc-900/10 px-3 text-[12px] font-medium tracking-tight text-zinc-700 outline-hidden transition-colors hover:bg-zinc-900/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ptr-accent)] aria-disabled:cursor-default aria-disabled:opacity-60 aria-disabled:hover:bg-transparent dark:border-white/10 dark:text-zinc-200 dark:hover:bg-white/[0.07]"
>
<motion.svg
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className="size-3.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
animate={status === "refreshing" && !reduceMotion ? { rotate: 360 } : { rotate: 0 }}
transition={status === "refreshing" && !reduceMotion ? { duration: 0.9, ease: "linear", repeat: Infinity } : { duration: 0 }}
>
<path d="M13.2 8a5.2 5.2 0 1 1-1.6-3.75" />
<path d="M13.3 2.6v2.9h-2.9" />
</motion.svg>
Refresh
</button>
) : null}
</div>
) : null}
<div className="relative min-h-0 flex-1">
{/* Indicator, revealed in the gap above the content (decorative: the live region speaks). */}
<motion.div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center justify-center gap-1.5 overflow-hidden"
style={{ height: y, opacity: indicatorOpacity }}
>
<motion.span className="relative grid size-7 place-items-center" style={{ scale: status === "idle" || status === "armed" ? indicatorScale : 1 }}>
<svg viewBox="0 0 28 28" className="absolute inset-0 size-full -rotate-90" fill="none">
<circle cx="14" cy="14" r="11" strokeWidth="2" className="stroke-zinc-900/10 dark:stroke-white/15" />
{status === "idle" || status === "armed" ? (
<motion.circle cx="14" cy="14" r="11" strokeWidth="2" strokeLinecap="round" stroke="var(--ptr-accent)" style={{ pathLength: progress }} />
) : null}
</svg>
{status === "refreshing" ? (
<motion.svg
viewBox="0 0 28 28"
className="absolute inset-0 size-full"
fill="none"
animate={reduceMotion ? undefined : { rotate: 360 }}
transition={{ duration: 0.8, ease: "linear", repeat: Infinity }}
>
<path d="M14 3a11 11 0 0 1 11 11" stroke="var(--ptr-accent)" strokeWidth="2" strokeLinecap="round" />
</motion.svg>
) : null}
{status === "done" ? (
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="var(--ptr-accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="m3.5 8.4 2.9 2.8 6.1-6.4" initial={{ pathLength: reduceMotion ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: reduceMotion ? 0 : 0.3 }} />
</svg>
) : status === "error" ? (
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="#D93D42" strokeWidth="2" strokeLinecap="round">
<path d="M8 4.2v4.6M8 11.4v.4" />
</svg>
) : status !== "refreshing" ? (
<motion.svg
viewBox="0 0 16 16"
className="size-3.5 text-zinc-500 dark:text-zinc-400"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
style={{ rotate: arrowRotate }}
>
<path d="M8 3.5v9M4.5 9 8 12.5 11.5 9" />
</motion.svg>
) : null}
</motion.span>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">{statusText}</span>
</motion.div>
<motion.div
ref={scrollerRef}
role="region"
aria-label={label}
aria-busy={status === "refreshing"}
tabIndex={0}
className={`relative h-full overflow-y-auto overscroll-y-contain outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--ptr-accent)] ${scrollClassName}`}
style={{ y }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
onClickCapture={(event) => {
// The click that ends a mouse pull is not a click on the content.
if (gesture.current.suppressClick) {
gesture.current.suppressClick = false;
event.preventDefault();
event.stopPropagation();
}
}}
>
<div ref={contentRef}>{children}</div>
</motion.div>
</div>
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{message}
</span>
</div>
);
}