All patterns

Pull to Refresh

Dragging past a threshold arms the refresh, and the indicator tracks the finger the whole way.

loadingfriendlyenergeticinteraction · finite · advanced · ~1.6s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

397 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import {
  animate,
  AnimatePresence,
  motion,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useTransform,
} from "motion/react";

/**
 * Vibary · Pull to Refresh
 *
 * The indicator is driven by the drag itself: the arc grows with the
 * distance travelled, flips to "armed" at the threshold, and holds while
 * the request runs. Nothing here is a canned clip — until the finger
 * lifts, every frame is a function of where the finger is.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; pass `onRefresh` to run your own fetch.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type PullToRefreshProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Your fetch. Resolve it when the new data is in hand. */
  onRefresh?: () => Promise<unknown> | void;
  /** Stand-in duration used only while `onRefresh` is omitted, in ms. */
  refreshMs?: number;
  /** Panel width — px number or any CSS length. */
  width?: number | string;
  /** Armed/active color. A state color, so it stays literal. */
  accent?: string;
};

type VariantConfig = {
  /** Travel needed before the gesture arms, in px. */
  threshold: number;
  /** Where the panel rests while the request runs, in px. */
  hold: number;
  /** Rubber-band resistance on the downward drag. */
  elastic: number;
  /** Seconds per turn of the active arc. */
  spinSeconds: number;
  /** Entry travel for a newly arrived row, in px. */
  rowLift: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: every spring here is at or above a 0.8 damping ratio. A
// pull-to-refresh that boings back past its rest position feels like a
// toy the second time you use it, and this gesture gets used constantly.
// Variants differ in how far you pull and how fast it settles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Short pull, tight snap. For dense inboxes on desktop-sized panels.
  subtle: {
    threshold: 54,
    hold: 44,
    elastic: 0.46,
    spinSeconds: 1,
    rowLift: 4,
    spring: { type: "spring", stiffness: 560, damping: 48 },
  },
  // The all-purpose setting: a pull you have to mean, a settle you can see.
  default: {
    threshold: 68,
    hold: 54,
    elastic: 0.56,
    spinSeconds: 0.85,
    rowLift: 8,
    spring: { type: "spring", stiffness: 480, damping: 42 },
  },
  // Longer travel and a softer landing — for a full-screen mobile feed.
  playful: {
    threshold: 82,
    hold: 62,
    elastic: 0.66,
    spinSeconds: 0.72,
    rowLift: 12,
    spring: { type: "spring", stiffness: 400, damping: 34 },
  },
};

const ACCENT = "#4C7DF0";

/** Theme-adaptive neutral: `currentColor` is the inherited text color —
 *  near-black on a light page, near-white on a dark one — so mixing it
 *  with `transparent` yields surfaces and borders that are correctly
 *  toned in either theme. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

type Row = { id: number; title: string; meta: string };

const SEED_ROWS: Row[] = [
  { id: 4, title: "Invoice #2481 approved", meta: "Finance · 14 min ago" },
  { id: 3, title: "Contract renewal drafted", meta: "Legal · 41 min ago" },
  { id: 2, title: "Q3 forecast shared", meta: "Analytics · 2 h ago" },
  { id: 1, title: "Seat count updated", meta: "Billing · 5 h ago" },
];

const INCOMING_ROWS: Row[] = [
  { id: 5, title: "Export finished", meta: "Reports · just now" },
  { id: 6, title: "New ticket assigned", meta: "Support · just now" },
  { id: 7, title: "Payout scheduled", meta: "Billing · just now" },
];

export default function PullToRefresh({
  variant = "default",
  onRefresh,
  refreshMs = 1300,
  width = 328,
  accent = ACCENT,
}: PullToRefreshProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const y = useMotionValue(0);
  const [armed, setArmed] = useState(false);
  const [busy, setBusy] = useState(false);
  const [rows, setRows] = useState<Row[]>(SEED_ROWS);
  const alive = useRef(true);

  useEffect(() => {
    alive.current = true;
    return () => {
      alive.current = false;
    };
  }, []);

  // One subscription instead of a re-render per frame: the component only
  // re-renders on the two frames where the gesture crosses the threshold.
  useMotionValueEvent(y, "change", (value) => {
    if (!busy) setArmed(value >= cfg.threshold);
  });

  // Everything the indicator does during the drag is a projection of the
  // drag distance. useTransform clamps at both ends, so overpulling past
  // the threshold changes nothing — the gesture is already armed.
  const shellOpacity = useTransform(y, [2, cfg.threshold * 0.4], [0, 1]);
  const shellScale = useTransform(y, [0, cfg.threshold], [0.74, 1]);
  const shellShift = useTransform(y, [0, cfg.hold], [-12, 0]);
  const arcLength = useTransform(y, [6, cfg.threshold], [0.05, 1]);
  const arrowTurn = useTransform(y, [cfg.threshold * 0.55, cfg.threshold], [0, 180]);

  const settle = reduceMotion
    ? { duration: 0.18, ease: "easeOut" as const }
    : cfg.spring;

  const runRefresh = async () => {
    setBusy(true);
    setArmed(false);
    animate(y, cfg.hold, settle);
    try {
      if (onRefresh) await onRefresh();
      else await new Promise((resolve) => setTimeout(resolve, refreshMs));
    } finally {
      if (alive.current) {
        setRows((current) => {
          const next = INCOMING_ROWS[current.length - SEED_ROWS.length];
          return next ? [next, ...current] : current;
        });
        setBusy(false);
        animate(y, 0, settle);
      }
    }
  };

  const label = busy
    ? "Refreshing"
    : armed
      ? "Release to refresh"
      : "Pull to refresh";

  return (
    <div
      style={{
        width,
        position: "relative",
        overflow: "hidden",
        borderRadius: 16,
        border: `1px solid ${tone(12)}`,
        background: tone(4),
        // Without this the browser claims the vertical gesture first and
        // the drag never reaches the component on a touch screen.
        touchAction: "pan-x",
      }}
    >
      {/* The indicator lives behind the panel and is uncovered by the
          drag, which is why it can't be a clip: it is only ever as
          visible as the gesture has made it. */}
      <motion.div
        aria-hidden
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          right: 0,
          height: cfg.hold,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          gap: 10,
          opacity: shellOpacity,
          y: shellShift,
        }}
      >
        <motion.div
          style={{
            scale: shellScale,
            width: 22,
            height: 22,
            display: "grid",
            placeItems: "center",
          }}
        >
          <svg width={22} height={22} viewBox="0 0 24 24" fill="none">
            <circle cx="12" cy="12" r="9.5" stroke={tone(14)} strokeWidth="2" />
            {/* Drag-tracked arc: pathLength is the gesture, expressed as a
                number between 0 and 1. */}
            <motion.circle
              cx="12"
              cy="12"
              r="9.5"
              stroke={armed ? accent : tone(38)}
              strokeWidth="2"
              strokeLinecap="round"
              transform="rotate(-90 12 12)"
              style={{ pathLength: arcLength, opacity: busy ? 0 : 1 }}
            />
            <motion.g
              style={{ transformOrigin: "12px 12px" }}
              animate={busy && !reduceMotion ? { rotate: 360 } : { rotate: 0 }}
              transition={
                busy && !reduceMotion
                  ? { duration: cfg.spinSeconds, repeat: Infinity, ease: "linear" }
                  : { duration: 0 }
              }
            >
              {/* Active arc: a quarter of the 59.7px circumference,
                  revealed only once the request is in flight. Reduced
                  motion gets the closed ring, since nothing will turn. */}
              <motion.circle
                cx="12"
                cy="12"
                r="9.5"
                stroke={accent}
                strokeWidth="2"
                strokeLinecap="round"
                strokeDasharray={reduceMotion ? undefined : "15.5 44.2"}
                transform="rotate(-90 12 12)"
                animate={{ opacity: busy ? 1 : 0 }}
                transition={{ duration: 0.16, ease: "easeOut" }}
              />
            </motion.g>
            <motion.path
              d="M12 7.6v8.8M8.6 13l3.4 3.4L15.4 13"
              stroke={armed ? accent : tone(42)}
              strokeWidth="1.8"
              strokeLinecap="round"
              strokeLinejoin="round"
              style={{
                rotate: reduceMotion ? 0 : arrowTurn,
                transformOrigin: "12px 12px",
              }}
              animate={{ opacity: busy ? 0 : 1 }}
              transition={{ duration: 0.16, ease: "easeOut" }}
            />
          </svg>
        </motion.div>

        {/* Fixed-size label slot: the wording changes, the type never
            moves or resizes. */}
        <div style={{ position: "relative", width: 118, height: 15 }}>
          <AnimatePresence initial={false}>
            <motion.span
              key={label}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.14, ease: "easeOut" }}
              style={{
                position: "absolute",
                inset: 0,
                fontSize: 12,
                lineHeight: "15px",
                fontWeight: 500,
                color: armed || busy ? accent : "inherit",
                opacity: 0.85,
                whiteSpace: "nowrap",
              }}
            >
              {label}
            </motion.span>
          </AnimatePresence>
        </div>
      </motion.div>

      {/* The sheet the user actually drags. Constraints pin it at rest;
          the elasticity is what makes the pull feel like resistance
          rather than free travel. */}
      <motion.div
        drag={busy ? false : "y"}
        dragDirectionLock
        dragConstraints={{ top: 0, bottom: 0 }}
        dragElastic={{ top: 0, bottom: cfg.elastic }}
        dragMomentum={false}
        onDragEnd={() => {
          if (y.get() >= cfg.threshold) void runRefresh();
          else animate(y, 0, settle);
        }}
        style={{
          y,
          position: "relative",
          background: tone(6),
          borderRadius: 16,
          cursor: busy ? "default" : "grab",
        }}
        aria-busy={busy}
      >
        <div
          style={{
            display: "flex",
            alignItems: "baseline",
            justifyContent: "space-between",
            padding: "13px 16px 11px",
            borderBottom: `1px solid ${tone(10)}`,
          }}
        >
          <span style={{ fontSize: 12, fontWeight: 600, letterSpacing: 0.4 }}>
            ACTIVITY
          </span>
          <span style={{ fontSize: 11, opacity: 0.5 }}>{rows.length} items</span>
        </div>

        <div style={{ padding: "4px 0 6px" }}>
          {rows.map((row, index) => (
            <motion.div
              key={row.id}
              // layout="position" moves the row without stretching it, so
              // the type inside never scales as the list grows.
              layout={reduceMotion ? false : "position"}
              initial={
                index === 0 && row.id > SEED_ROWS.length
                  ? { opacity: 0, y: reduceMotion ? 0 : -cfg.rowLift }
                  : false
              }
              animate={{ opacity: 1, y: 0 }}
              transition={{
                opacity: { duration: 0.26, ease: "easeOut" },
                y: cfg.spring,
                layout: cfg.spring,
              }}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 11,
                padding: "9px 16px",
              }}
            >
              <span
                style={{
                  width: 6,
                  height: 6,
                  borderRadius: 3,
                  flexShrink: 0,
                  background: row.id > SEED_ROWS.length ? accent : tone(22),
                }}
              />
              <span style={{ minWidth: 0 }}>
                <span
                  style={{
                    display: "block",
                    fontSize: 13,
                    fontWeight: 500,
                    lineHeight: 1.3,
                  }}
                >
                  {row.title}
                </span>
                <span
                  style={{ display: "block", fontSize: 11.5, opacity: 0.52, marginTop: 2 }}
                >
                  {row.meta}
                </span>
              </span>
            </motion.div>
          ))}
        </div>
      </motion.div>
    </div>
  );
}

About this pattern

The gesture people already know from every mobile feed, built the way it has to be built: the arc, the scale and the arrow are all projections of the drag distance, so the indicator is wherever the finger left it rather than wherever a timeline says it should be. Crossing the threshold arms it — the arc closes, the arrow turns, the wording changes — and releasing early rubber-bands straight back with nothing spent. Only after release does the component take over: it holds the panel open, spins a quarter arc while the request runs, and collapses once the new row has landed. Because the pull is elastic and the snap-back is over-damped, the panel never overshoots its rest position, which is what separates a gesture that feels attached to the finger from one that feels like a video being scrubbed.

Feed refreshInbox reloadActivity listMobile list refetch

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Inbox
    Starred
    Drafts
    Archive
    Sent
    InboxNew
    Contract renewalPriya Raman · 10:14
    Q3 hiring planMarcus Bell · 09:02
    Venue confirmed for ThursdayDana Whitfield · Tue
    Invoice 4821 clearedBilling · Tue
    Weekly summaryReports · Mon
    Inbox

    The message list stretches under the finger and holds a spinner open while it checks for new mail.

Related patterns