A web-adapted pull-to-refresh scroll region with a rubber-band resistance curve and a settle back home.
npx shadcn@latest add @paragon/pull-to-refresh"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
/** Pull distance (px) past which release triggers a refresh. */
const THRESHOLD = 72;
/** Rubber-band: asymptotic resistance so the pull never runs away. */
function resist(distance: number) {
return distance / (1 + distance / 140);
}
export interface PullToRefreshProps
extends Omit<React.ComponentProps<"div">, "onScroll"> {
/** Called on release past threshold; resolve to end the spinner. */
onRefresh: () => void | Promise<void>;
/** Height of the scroll region. */
maxHeight?: number | string;
static?: boolean;
}
/**
* Web-adapted pull-to-refresh. When the scroll region is at the top, dragging
* down pulls the content with a rubber-band resistance curve and rotates the
* spinner toward "release to refresh"; letting go past the threshold snaps to
* a loading offset, runs `onRefresh`, then settles home. The gesture only
* arms at scrollTop 0, so normal scrolling is untouched, and it's pointer-
* captured + single-touch guarded. Reduced motion skips the pull visual and
* still refreshes on an over-scroll release.
*/
export function PullToRefresh({
onRefresh,
maxHeight = 320,
static: isStatic = false,
className,
children,
...props
}: PullToRefreshProps) {
const reduced = useReducedMotion() ?? false;
const scrollRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const indicatorRef = React.useRef<HTMLDivElement>(null);
const iconRef = React.useRef<HTMLDivElement>(null);
const [refreshing, setRefreshing] = React.useState(false);
const [armed, setArmed] = React.useState(false);
const drag = React.useRef({
pointerId: null as number | null,
startY: 0,
pulling: false,
distance: 0,
});
const disabled = isStatic || reduced;
const paint = (offset: number) => {
const el = contentRef.current;
const ind = indicatorRef.current;
const icon = iconRef.current;
if (el) {
el.style.transition = "none";
el.style.transform = `translate3d(0, ${offset}px, 0)`;
}
if (ind) {
ind.style.transition = "none";
ind.style.opacity = String(Math.min(1, offset / THRESHOLD));
}
if (icon) {
icon.style.transition = "none";
icon.style.transform = `rotate(${(offset / THRESHOLD) * 180}deg)`;
}
};
const settle = (to: number) => {
const el = contentRef.current;
const ind = indicatorRef.current;
if (el) {
el.style.transition = "transform 250ms var(--ease-out)";
el.style.transform = `translate3d(0, ${to}px, 0)`;
}
if (ind) {
ind.style.transition = "opacity 200ms var(--ease-out)";
ind.style.opacity = to > 0 ? "1" : "0";
}
};
const runRefresh = async () => {
setRefreshing(true);
settle(THRESHOLD);
try {
await onRefresh();
} finally {
setRefreshing(false);
settle(0);
}
};
return (
<div
className={cn(
"relative overflow-hidden rounded-xl bg-card shadow-border",
className,
)}
{...props}
>
{/* Refresh indicator behind the content. */}
<div
ref={indicatorRef}
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 flex h-16 items-center justify-center opacity-0"
>
<div
ref={iconRef}
className={cn(
"flex size-8 items-center justify-center rounded-full bg-secondary text-muted-foreground shadow-border",
refreshing && "animate-spin",
)}
>
<RefreshCw className="size-4" />
</div>
</div>
<div
ref={scrollRef}
className="overflow-y-auto overscroll-contain"
style={{ maxHeight }}
role="status"
aria-live="polite"
onPointerDown={(e) => {
if (disabled || refreshing) return;
if (e.pointerType === "mouse") return; // touch/pen gesture only
if (drag.current.pointerId !== null) return;
if ((scrollRef.current?.scrollTop ?? 0) > 0) return;
drag.current.pointerId = e.pointerId;
drag.current.startY = e.clientY;
drag.current.pulling = false;
drag.current.distance = 0;
}}
onPointerMove={(e) => {
const state = drag.current;
if (state.pointerId !== e.pointerId) return;
const dy = e.clientY - state.startY;
if (!state.pulling) {
if (dy <= 6) return;
if ((scrollRef.current?.scrollTop ?? 0) > 0) {
state.pointerId = null;
return;
}
state.pulling = true;
e.currentTarget.setPointerCapture(e.pointerId);
}
if (dy <= 0) {
state.distance = 0;
paint(0);
setArmed(false);
return;
}
const offset = resist(dy);
state.distance = offset;
paint(offset);
setArmed(offset >= THRESHOLD);
}}
onPointerUp={(e) => {
const state = drag.current;
if (state.pointerId !== e.pointerId) return;
state.pointerId = null;
const past = state.distance >= THRESHOLD;
state.pulling = false;
setArmed(false);
if (past) runRefresh();
else settle(0);
}}
onPointerCancel={(e) => {
const state = drag.current;
if (state.pointerId !== e.pointerId) return;
state.pointerId = null;
state.pulling = false;
setArmed(false);
settle(0);
}}
>
<div
ref={contentRef}
style={{ transform: "translate3d(0,0,0)" }}
className="will-change-transform"
>
<span className="sr-only">
{refreshing
? "Refreshing"
: armed
? "Release to refresh"
: ""}
</span>
{children}
</div>
</div>
</div>
);
}