Pull to refresh

Free · MITReact Native · Expo

A scroll view whose refresh indicator is a drop that stretches as you pull, snaps round with a bounce and a haptic past the threshold, spins while onRefresh runs and ends on a check or an error.

View as Markdown
Try it on your phone
Scan to open this demo in your phone's browser and use it with your fingers.

Installation

$ npx shadcn@latest add https://goodui.dev/native/r/pull-to-refresh.json

Then run npx expo install --fix so react-native-gesture-handler, react-native-reanimated, react-native-svg match your Expo SDK. Files land in components/goodui-native/pull-to-refresh.

Usage

import { PullToRefresh } from "@/components/goodui-native/pull-to-refresh/pull-to-refresh";

export function Inbox() {
  const { messages, reload } = useMessages();
  return (
    <PullToRefresh onRefresh={reload} contentContainerStyle={{ padding: 16, gap: 8 }}>
      {messages.map((m) => (
        <MessageRow key={m.id} message={m} />
      ))}
    </PullToRefresh>
  );
}

When to use

Use it for
  • Screens whose content changes on the server: inboxes, feeds, order lists, account activity.
  • A screen that is one scrolling column of content, where the pull starts from the top.
Reach for something else
  • Virtualized lists with thousands of rows (FlatList, FlashList): use their refreshControl with React Native's RefreshControl.
  • The first load of a screen: show skeleton placeholders instead of an empty, pulled-open view.
  • Content that updates live on its own: show the change, do not ask people to pull.

Examples

Accent indicator, custom captions

<PullToRefresh
  tone="accent"
  onRefresh={() => api.syncOrders()}
  labels={{ success: "Orders synced", error: "Sync failed" }}
>
  {orders}
</PullToRefresh>

Refreshing from outside

<PullToRefresh refreshing={isSyncing} onRefresh={sync}>{content}</PullToRefresh>

API reference

PropTypeDefault
children*
Scroll content.
ReactNode—
onRefresh*
Runs when a pull is released past the threshold. Return a promise to hold the spinner until it settles.
() => unknown—
refreshing
Show the refreshing state from outside, for example on first load.
boolean—
threshold
Pull distance in points that arms the refresh. Default 72.
number72
tone
Indicator color: `neutral` foreground or the theme `accent`.
"neutral" | "accent""neutral"
haptic
Haptic when the pull passes the threshold. `false` turns it off.
HapticName | false"tap"
labels
Overrides for the built-in strings (captions and the refresh strip); a GooduiLocaleProvider pack sets them app-wide.
Partial<PullToRefreshLabels>—
webHandle
On the web, a strip at the top that can be dragged with a mouse or clicked to refresh. Screen reader users get it as a Refresh button on every platform. Default `true`.
booleantrue
scrollY
Receives the vertical scroll offset, for example to collapse a LargeTitleHeader above the list. It is measured from the top of your content, below the refresh strip when that shows, so a LargeTitle collapses on time.
SharedValue<number>—
contentContainerStyle
Style of the scroll content container.
StyleProp<ViewStyle>—
style
Style for the outer container.
StyleProp<ViewStyle>—
testID
Identifier for end-to-end tests.
string—

Motion and haptics

  • The content follows the finger with growing resistance; the drop in the gap stretches with the pull.
  • Passing the threshold snaps the drop round on the bouncy spring and turns its arrow upward; pulling back un-arms it on the snappy spring.
  • Releasing armed holds the content open on the smooth spring while a spinner runs; the result shows a drawn check or an alert for 750 ms, then everything tucks away.
  • Reduced motion: the drop never stretches or bounces, the arrow flips, and settling uses a short timing curve instead of a spring.
  • Haptic: tap when the pull passes the threshold (configurable with the haptic prop)
  • Haptic: success when onRefresh resolves, error when it rejects
  • The pull, the drop and the content offset run on the UI thread; React renders only when the state changes (armed, refreshing, result).
  • The spinner shows for at least 450 ms, so a fast refresh never flashes.

Accessibility

  • With a screen reader on, a Refresh button appears at the top of the content on every platform, so nobody has to perform the pull gesture.
  • The scroll view reports busy while refreshing; the result is announced with the success or error label.
  • The indicator itself is hidden from screen readers.
  • On the web the refresh strip is named by its visible text and Enter or Space refreshes.

Notes for AI

  • Return the promise from onRefresh. Do not keep your own refreshing flag unless the refresh starts somewhere else; then pass it as refreshing.
  • It is the screen's scroll view: do not nest it inside another ScrollView.
  • The scroll view does not bounce on iOS (the pull replaces the top bounce), so content stops firmly at the bottom.
  • On the web, a mouse drags the content down to refresh, and the top strip can be clicked; set webHandle={false} to hide the strip.
  • To collapse a large title above the list, pass a shared value as scrollY and give the same one to LargeTitleHeader and LargeTitle (render LargeTitle as the first child). scrollY counts from the top of your content, below the web refresh strip, so the title collapses on time on every platform.