Mobile & Consumer

Pull to Refresh

A web-adapted pull-to-refresh scroll region with a rubber-band resistance curve and a settle back home.

Install

npx shadcn@latest add @paragon/pull-to-refresh

pull-to-refresh.tsx

"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>
  );
}