Pull To Refresh React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React pull to refresh wrapper: pull a list past its top to reload it, with rubberband resistance, iOS spokes or a Material ring, haptics.
At the top of the list, drag down with a finger: the content follows with growing resistance and the indicator appears in the gap (an arrow, the iOS spokes drawing one by one, or a Material circle whose ring fills). Past the threshold the arrow turns, the spokes complete or the ring saturates, and the phone ticks once. Let go there and it spins while onRefresh runs (500ms at least), shows a check (or an alert when the promise rejects) and the content glides back; let go earlier and it springs back. Swiping sideways or up is left to the page, and pulls never start in inputs, [data-no-pull], a sideways scroller or a nested list that is not at its own top. On a trackpad or mouse wheel, scroll up once more at the very top to pull; stop for a moment to release. With pullWithMouse a mouse drag pulls too. There is no gesture on the keyboard: bind shortcut, or call setApi().refresh() from a refresh button. Screen readers hear Refreshing, then Updated or the error.
Pull To Refresh is a Pro React component in the React mobile components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add pull-to-refresh, together with Spinner, useScrollState, useShortcut, Haptics, Motion presets and 2 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add pull-to-refreshPull To Refresh is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import PullToRefresh from @/components/ui/pull-to-refresh.
"use client"
import { useState } from "react"
import { RotateCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { PullToRefresh, type PullToRefreshApi } from "@/components/ui/pull-to-refresh"
type Message = { id: string; from: string; text: string }
export function Inbox({ load }: { load: () => Promise<Message[]> }) {
const [messages, setMessages] = useState<Message[]>([])
const [api, setApi] = useState<PullToRefreshApi>()
return (
<div className="flex h-dvh flex-col">
<header className="flex h-14 items-center justify-between px-4">
<h1 className="text-lg font-semibold">Inbox</h1>
<Button variant="ghost" size="icon" radius="full" aria-label="Reload" onClick={() => api?.refresh()}>
<RotateCw />
</Button>
</header>
<PullToRefresh
scroll="self"
className="min-h-0 flex-1"
setApi={setApi}
onRefresh={async () => setMessages(await load())}
labels={{ pull: "Pull to check for mail", release: "Let go to check", refreshing: "Checking for mail…", done: "Up to date", error: "Could not check for mail" }}
>
<ul className="divide-y divide-foreground/10">
{messages.map((m) => (
<li key={m.id} className="px-4 py-3">
<p className="font-medium">{m.from}</p>
<p className="text-sm text-muted-foreground">{m.text}</p>
</li>
))}
</ul>
</PullToRefresh>
</div>
)
}Props
childrenReactNodeThe list or feed that gets pulled.
onRefresh(() => void | Promise<unknown>)The reload. The indicator spins until the returned promise settles, and for 500ms at least so it never flashes. A rejection shows the error state.
variantdefault"arrow""arrow" | "ios" | "material"arrow: an arrow that turns at the threshold and becomes a spinner; the content moves down. ios: 12 spokes that draw one by one as you pull, then spin like UIRefreshControl; the content moves down. material: a floating circle whose ring fills while you pull, then spins; the content stays put.
sizedefault"md""sm" | "md" | "lg"Indicator 20 / 24 / 28px; the material circle 36 / 40 / 44px.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The indicator color. neutral is the quiet muted gray (the material ring is black / white); the other tones use their tone text color and fill.
colordefault""stringAny CSS color for the indicator. Replaces the tone. Leave empty to use the tone.
showLabeldefaultfalsebooleanText under the indicator that follows the state: pull, release, refreshing, done or error.
labelsPartial<PullToRefreshLabels>The strings shown with showLabel and announced to screen readers: pull "Pull to refresh", release "Release to refresh", refreshing "Refreshing…", done "Updated", error "Couldn't refresh". Pass only the keys you translate.
refreshingdefaultfalsebooleanShows the refreshing state for a reload the app started itself (the content parks at the threshold with a spinner). OR-ed with the internal state, so false never blocks a pull.
disableddefaultfalsebooleanNo pulling (offline, editing, a filter is active). Turning it on mid-pull springs back without refreshing.
doneDurationdefault600numberMilliseconds the check (or the alert) stays before the content glides back. 0 skips the done state.
thresholddefault72numberPixels of resisted pull that arm the refresh; also where the content rests while it refreshes.
maxPulldefault128numberThe furthest the content moves, however far the finger goes.
indicatorOffsetdefault0numberPixels from the top of the root to the indicator's resting line, for a sticky or translucent bar over the list.
scrolldefault"parent""parent" | "self"parent: watches scrollTarget, else the provided scroll container (ScrollContainerProvider), else the nearest scrolling ancestor, else the window. self: the root is the scroll container (h-full overflow-y-auto overscroll-y-contain).
scrollTargetHTMLElement | Window | RefObject<HTMLElement | null> | nullThe scroller to watch when scroll is parent. null forces the window.
wheeldefaulttruebooleanA trackpad or mouse wheel scrolling up at the very top pulls too, the desktop path. Momentum that carries a scroll up to the top never pulls.
pullWithMousedefaultfalsebooleanA mouse drag at the top pulls, for kiosks and touch emulation. Off by default so it never fights text selection and clicks.
hapticsdefaulttruebooleanA "medium" tick when the pull arms and an "error" buzz when a pulled refresh fails, through lib/haptics.
shortcutdefault""stringA key that refreshes, in mod+k notation ("mod+r", "shift+r"). None by default.
setApi(api: PullToRefreshApi) => voidReceives { refresh(): Promise<void> }, which runs the same flow as a pull (indicator, promise, done). Calls during a running refresh return the running promise.
classNamesPartial<Record<"indicator" | "icon" | "spinner" | "label" | "content", string>>Extra classes for the indicator row, the arrow / check / alert icon, the spinner, the label and the content wrapper.
classNamestringExtra classes for the root, merged last.
Parts
DEFAULT_PULL_TO_REFRESH_LABELS
constantThe English copy: pull "Pull to refresh", release "Release to refresh", refreshing, done "Updated" and error "Couldn't refresh".
PullToRefreshApi
typeWhat setApi hands you: { refresh(): Promise<void> }, to run the same refresh (indicator included) from a button or a keyboard shortcut.
Examples
Three variants
arrow turns and becomes a spinner, ios draws its spokes, material floats a ring over a list that stays put.
Custom labels
showLabel with custom labels; the header button runs the same flow through setApi().refresh().
Failure
While Offline is on the request rejects: an alert, Could not check for mail, and an error haptic.
Under a large title
scroll="parent" watches the frame's ScrollContainerProvider; the large title stays, the list moves.
Around a List
The list's own refreshing bar mirrors the promise; material keeps the rows in place.
Brand on a feed
tone="brand" material over a marketplace grid.
Disabled while filtering
Typing a search turns disabled on, so a pull on filtered results does nothing.
Files
- components/ui/pull-to-refresh.tsx
FAQ
Is the React Pull To Refresh component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Pull To Refresh?
Run npx wingo-ui@latest add pull-to-refresh. It also adds the Wingo UI items it builds on (Spinner, useScrollState, useShortcut, Haptics, Motion presets and 2 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/pull-to-refresh.
See also
Used in
More React mobile components
Articles using this component
- React Native vs PWA in 2026: Which One Do You Need?
Comparisons and alternatives12 min read
- Web App That Feels Native on a Phone: 12 React Fixes
Mobile UI in React11 min read
- Best React Component Library for Mobile in 2026: 9 Compared
Comparisons and alternatives12 min read
- Mobile-First React Components: 9 Rules With Code
Mobile UI in React21 min read