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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/pull-to-refresh.jsonPrompt
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 slider —
distance = 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 passingmaxPull. 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. preventDefaultneeds a non-passive listener — React registersonTouchMovepassively, and a passive listener may not cancel, so the touch listeners are attached natively with{ passive: false }. The call is still guarded byevent.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: nonefinishes 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
onRefreshresolves or rejects, and at leastminDurationeither 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 reportsaria-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 underprefers-reduced-motionthe rotation and the eases are dropped while the pull, the status text and the announcement stay exactly as they were.
Popconfirm
A confirmation anchored to the control that raised it — non-modal, keyboard-complete, one-shot, with a pending state and an inline rejection that keeps the panel open.
Undo Toast
An optimistic-delete toast: the row disappears at once, a countdown ring holds the destructive commit, and hover or focus pauses the window.