Pull to refresh
Free · MITReact Native · ExpoA 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.
Installation
$ npx shadcn@latest add https://goodui.dev/native/r/pull-to-refresh.jsonThen 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
- 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.
- 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
| Prop | Type | Default |
|---|---|---|
| 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. | number | 72 |
| 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`. | boolean | true |
| 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.
Related
Loading placeholders whose shimmer is one light passing diagonally over the whole screen, plus presets and a Swap that eases from the skeleton's height to the content's while it cross-fades.
Background confirmations that stack at the top, fan out on tap, swipe away up or sideways, and pause their timers while touched.
Row actions that unfold with the finger; a long swipe lets the edge action take over and run, and destructive actions collapse the row before it leaves.