Feedback

Pull To Refresh

A pull-down-to-refresh wrapper: a rubber-banded drag claimed only while the scroller is at the top, a threshold that arms the release, an async refresh held until it settles, and a keyboard-reachable refresh button.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowDown, Check, CircleAlert, RefreshCw } from "lucide-react"
import { cn } from "@/lib/utils"

/** Where the gesture is in its life cycle. Also mirrored onto the root as `data-phase`. */
export type PullToRefreshPhase = "idle" | "pull" | "armed" | "refresh" | "done" | "error"

export interface PullToRefreshLabels {
  /** Shown while the pull is below the threshold. */
  pull: string
  /** Shown at or past the threshold — letting go now fires the refresh. */
  release: string

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/pull-to-refresh.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "PullToRefresh" component — the mobile
gesture where dragging a list down from its top reloads it. React + lucide-react
only: no gesture library, no animation library.

Contract
- "use client". forwardRef<HTMLDivElement, PullToRefreshProps> extending
  React.HTMLAttributes<HTMLDivElement>; the rest props spread onto the root.
- Props:
  - onRefresh: () => void | Promise<unknown> — required. Runs when an armed pull
    is released AND when the refresh button is pressed. If it returns a promise,
    the indicator is held open until that promise settles; a rejection paints the
    error state. Rejections are caught and swallowed on purpose (an unhandled
    rejection is worse); the documented recipe for logging is to catch, report
    and rethrow inside the consumer's own handler.
  - threshold = 64 — pull distance in px, AFTER resistance, at which the gesture
    arms. Clamped 24..400.
  - maxPull = 1.875 * threshold — hard cap on the pull; clamped to at least
    1.4 * threshold, because the rubber band only approaches its cap
    asymptotically and a cap near the threshold is a gesture that can never arm.
  - minDuration = 600 — floor in ms on the refreshing state, measured from when
    the work started, so a 40ms cache hit does not flash.
  - disabled = false — the pull is never claimed and the button reports
    aria-disabled.
  - allowMouseDrag = false — also drive the pull with a held mouse button. Off by
    default: on desktop the gesture competes with text selection, and the button
    already covers mouse and keyboard.
  - showRefreshButton = true, refreshButtonLabel = "Refresh now",
    label = "Refreshable content" (the scroll region's accessible name).
  - labels?: Partial<{ pull; release; refreshing; done; error }> — the five
    status strings, for i18n.
  - children: the scrollable content.
- Export the phase union "idle" | "pull" | "armed" | "refresh" | "done" | "error"
  and mirror it onto the root as data-phase.
- Clamp every numeric prop through one helper that rejects NaN: a NaN threshold
  arms on contact.

Behavior — claim narrowly, then own the gesture
- Structure: a clipping root (position: relative, overflow: hidden, its own
  height) > an absolutely positioned indicator pinned to the top edge + the
  scroll container (overflow-y: auto) holding children. Pulling translates the
  SCROLL CONTAINER down and grows the indicator's height by the same amount, so
  the indicator exactly fills the gap it opened. Transform the container itself,
  never its contents — translating content inside a scroller changes its
  scrollable overflow area and makes the scrollbar twitch.
- touchstart records the start point and whether scrollTop <= 0 ("eligible").
- The gesture is claimed only on the first movement past an 8px axis lock, and
  only if it is eligible AND downwards AND more vertical than horizontal. A drag
  that fails the test is abandoned for the rest of the gesture, never re-tested:
  a diagonal flick must not become a pull halfway through, and a drag that starts
  scrolled down must not hijack the scroll when it reaches the top.
- On claiming, re-baseline startY to the current position so the pull starts at 0
  instead of jumping the 8px lock distance.
- Distance is rubber-banded: distance = maxPull * (1 - exp(-raw / maxPull)). It
  tracks the finger 1:1 for the first pixels, gets heavier the further it goes,
  and is mathematically incapable of passing maxPull.
- Once claimed, every touchmove calls preventDefault — which requires attaching
  the touch listeners NATIVELY with { passive: false }, because React registers
  onTouchMove passively and a passive listener may not cancel. Guard the call
  with event.cancelable: a touchmove stops being cancelable once the browser has
  committed to a scroll, and cancelling it then only logs a console warning.
- Set overscroll-behavior-y: none on the scroller. This component draws its own
  rubber band, so the platform bounce would fight it and scroll chaining would
  drag the page behind it.
- A second finger (pinch-zoom) or a touchcancel abandons the gesture and eases
  the panel home without refreshing.
- Release: distance >= threshold fires onRefresh; anything less eases back to 0.
  A refusal (disabled, or a refresh already in flight) must also settle the panel
  — never leave it parked open.
- Refresh lifecycle: refresh -> hold for max(promise, minDuration) -> done (tick,
  700ms) or error (cross, 1600ms) -> collapse to idle.
- The one-shot guarantee is a ref read AND written synchronously inside the
  handler (busyRef), not state: a double tap, or a tap landing on the button
  during the settle animation, must not start a second request.
- Mouse mode: use Pointer Events filtered to pointerType === "mouse" (touch is
  already handled by the touch listeners, so nothing is handled twice), take
  setPointerCapture only AFTER the gesture is claimed (capturing on pointerdown
  would rewrite the click target for ordinary clicks on children), and swallow
  the click that follows a released drag with an onClickCapture guard so a drag
  never activates the row it ended over.
- Cleanup: every listener is removed and any in-flight gesture is ended in the
  effect's cleanup; the settle timers are cleared on unmount; async completion
  checks a mounted ref before scheduling anything.

Rendering & styling
- Semantic tokens only: root bg-card + border + rounded-lg; indicator text
  text-muted-foreground, text-foreground when armed, text-primary when done,
  text-destructive when it failed; button border + bg-card + hover:bg-accent /
  hover:text-accent-foreground; focus-visible:ring-2 ring-ring everywhere
  (ring-inset on the scroll region, whose ring would otherwise be clipped).
- The moving pixels are written straight to the DOM (transform on the scroller,
  height + opacity on the indicator, rotation on the icon) from the move handler.
  Only the phase lives in React state — a 60fps drag must not re-render the
  subtree, and the status text only changes at the two phase boundaries. Keep ONE
  writer: an effect paints the resting distance for every phase the finger is not
  driving, and it runs after the transition classes are back on, which is what
  makes the release ease home instead of snapping.
- The transition classes are removed while dragging (the finger IS the animation)
  and restored for everything else: transition-transform on the scroller,
  transition-[height,opacity] on the indicator, duration-300 ease-out.
- Icon: an arrow that rotates 0 -> 180deg across the pull (pointing up = release),
  swapped for a spinning RefreshCw while refreshing, a Check when done and a
  CircleAlert on failure.
- Accessibility:
  - Keyboard map: Tab reaches the refresh button (Enter / Space activate it — it
    is a real <button>, so that is free) and the scroll region itself; inside the
    region every native scroll key keeps working untouched (Arrow Up / Down,
    PageUp / PageDown, Home / End, Space). Deliberately NO custom key handler: a
    pull has no discrete keyboard analogue, and rebinding a key inside a scroll
    region would break the platform behaviour the region exists for.
  - The indicator is aria-hidden — it is a visual echo of the live region.
  - A polite sr-only role="status" announces the start and the outcome only,
    never the drag: announcing every pixel would be a screen-reader storm. Clear
    the string afterwards so the next identical result is announced again.
  - The scroll container is role="region" + aria-label + tabIndex 0 so keyboard
    users can scroll it, and aria-busy while the refresh is in flight.
  - The refresh button is a real <button type="button"> that never takes the
    native disabled attribute — the browser blurs a node the instant it becomes
    disabled, so a button that disables itself while refreshing would drop focus
    onto <body>. Use aria-disabled plus a guard in the handler.
  - prefers-reduced-motion (subscribed with useSyncExternalStore over matchMedia,
    never read during render): drop the continuous icon rotation (the arrow still
    flips once, discretely, at the arm point) and every transition. The pull
    itself still follows the finger — that is direct manipulation, not
    decoration — and the status text carries the state either way.

Customization levers
- Feel lives in two numbers: threshold (how far is "on purpose") and maxPull (how
  much travel there is past it). 64/120 is the phone default; 96/220 makes a
  destructive resync deliberate; 40/80 suits a short panel inside a card.
- minDuration is the anti-flicker knob, exactly like a loading bar's: raise it if
  your endpoint is fast enough that the tick would flash by.
- labels is the i18n seam; nothing else in the component contains prose.
- Height belongs to the consumer: className="h-96" / "h-full" merges last via
  cn(). Give the root a height, or the inner scroller has no range to scroll.
- Skin the indicator by targeting data-slot="pull-to-refresh-indicator", or the
  whole component by phase via data-phase (e.g. a tinted background while armed).
- Replace the floating button with your own header action:
  showRefreshButton={false} plus your button calling the same loader. Keep ONE of
  the two, and never zero — a touch-only refresh is unreachable by keyboard.
- allowMouseDrag turns the desktop drag on; leave it off for text-heavy content.
- To make the refresh state controllable from outside (an app-level "refreshing"
  flag), keep the phase machine and add a prop that forces the refresh phase —
  the paint effect already treats every non-drag phase as a resting distance.

Concepts

  • Claim narrowly, then own it — the gesture belongs to the browser until three things are true at once: the scroller is genuinely at scrollTop <= 0, the drag has passed an 8px axis lock, and it is downwards and more vertical than horizontal. A drag that fails the test is abandoned for good rather than re-tested each frame, so a diagonal flick can never turn into a pull halfway through and a drag that started scrolled down can never hijack the scroll when it reaches the top.
  • Rubber band, not a sliderdistance = maxPull * (1 - exp(-raw / maxPull)) tracks the finger 1:1 for the first pixels, gets heavier the further you go, and is mathematically incapable of passing maxPull. Resistance is what makes the pull feel like a physical thing being stretched; the cap is what stops a determined drag from opening a 600px hole in the layout.
  • preventDefault needs a non-passive listener — React registers onTouchMove passively, and a passive listener may not cancel, so the touch listeners are attached natively with { passive: false }. The call is still guarded by event.cancelable: once the browser has committed to a scroll the event is no longer cancelable and cancelling it only logs a warning. overscroll-behavior-y: none finishes the job by switching off the platform bounce that would otherwise fight the band being drawn here.
  • Armed is a state, not a prediction — crossing the threshold changes the phase, the wording ("release to refresh") and the arrow direction before the finger lifts, so the user knows what letting go will do. Releasing under the threshold, a second finger arriving, a touchcancel, or a refusal (disabled, already refreshing) all settle the panel back home; a refusal that left the panel parked open would be the worst bug this component could ship.
  • Held until it settles — the indicator is owned by the promise, not by a timer: it stays up until onRefresh resolves or rejects, and at least minDuration either way so a cache hit does not flash. The one-shot guard is a ref read and written synchronously inside the handler, because a state-only flag lets a double tap through.
  • Every gesture needs a button — a touch-only feature is unreachable by keyboard, so the same refresh runs from a real focusable <button> that reports aria-disabled (never the native attribute, which would blur it to <body> mid-refresh). Start and outcome are announced once in a polite live region — never the drag itself — and under prefers-reduced-motion the rotation and the eases are dropped while the pull, the status text and the announcement stay exactly as they were.

On This Page