About this animation
Related guides
A pull-to-refresh wrapper with a floating ghost character and configurable content, colors, labels, and font.
Why this one
The character stretches with your finger, looks sideways, smiles at the threshold, and opens its mouth with six lunar sparks on release. Its asynchronous lifecycle follows your data request, rather than a fixed spinner timer. All character geometry is original Skia artwork, with no bitmap assets.
How it works
- A manually activated pan recognizes downward movement only when the scroll offset is at the top. The native scroll gesture remains simultaneous, so ordinary scrolling continues.
- One
pullshared value drives the reveal, scale, elastic hem, labels, and list displacement. Overshoot beyondthresholdhas 0.55 resistance with a bounded rubber band. - Release above the threshold calls
onRefreshonce. The ghost pops with an explicit mass-one spring and floats on the UI thread while the promise runs. - Completion triggers a wink, a 360 ms stretch-and-fade exit, a spring return, and a border-light flash around the first row. Errors dismiss the character and call
onError, without the success flash. refresh()simulates a 650 ms pull before starting the same lifecycle. Repeated requests share the active promise. Touch can interrupt the simulated pull or dismissal; it never duplicates an in-flight data request.
Install
npx expo install @shopify/react-native-skia@2.14.0 react-native-gesture-handler@~3.3.0 react-native-reanimated@4.7.1 react-native-worklets@0.13.0Wrap your app in GestureHandlerRootView. Configure Reanimated/worklets for your Expo project. On web, initialize CanvasKit before importing this module (use your app's Skia loading boundary). The demo additionally uses expo-blur and expo-linear-gradient; these are not component dependencies. Demo icons: Lucide (ISC).
Usage
Pass ordinary rows as children; the component supplies the scroll view. Do not nest a scroll view in children.
import { GhostPullRefresh } from './ghost-pull-refresh';
<GhostPullRefresh onRefresh={reloadEvents}>
{events.map((event) => <EventRow key={event.id} event={event} />)}
</GhostPullRefresh>Use renderList for a virtualized list. The returned list must be the actual native scrollable element, not an extra wrapper or a Gesture Handler scroll component that already installs its own native handler.
import { useRef } from 'react';
import Animated from 'react-native-reanimated';
import { GhostPullRefresh, type GhostPullRefreshRef } from './ghost-pull-refresh';
const refreshRef = useRef<GhostPullRefreshRef>(null);
<GhostPullRefresh
ref={refreshRef}
onRefresh={reloadEvents}
threshold={110}
characterSize={64}
colors={{ background: '#FAF8FF', text: '#251B36', ground: '#B9B2D9' }}
labels={{ pull: 'Pull for updates', release: 'Release to refresh' }}
renderList={({ onScroll }) => (
<Animated.FlatList
data={events}
renderItem={renderEvent}
keyExtractor={(event) => event.id}
onScroll={onScroll}
scrollEventThrottle={16}
bounces={false}
overScrollMode="never"
/>
)}
/>
// Wire an external accessible refresh control to this method.
await refreshRef.current?.refresh();Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
onRefresh | () => Promise<void> | Required | Update data; loading lasts until settlement. |
onError | (error: unknown) => void | — | Handle a rejected request. |
threshold | number | 120 | Pull distance to arm refresh, in points. |
colors | Partial<GhostColors> | Moonlit palette | Body, bodyShade, shadow, eyes, highlight, cheeks, glow, ground, background, text, accent. |
labels | Partial<GhostLabels> | Pull to summon / Release to haunt / Summoning… / Refresh list | Status and accessible action copy. |
characterSize | number | 72 | Character width; scales to fit the wrapper. |
showLabel | boolean | true | Display status copy. |
children | ReactNode | — | Non-scrollable rows in the provided scroll view. |
renderList | (bindings: GhostListBindings) => ReactElement | — | Custom animated list; takes precedence over children. |
fontFamily | string | System | Label font. |
labelSize | number | 12 | Label point size. |
onInteraction | () => void | — | Physical touch notification; use to stop host autoplay. |
style | StyleProp<ViewStyle> | — | Flex wrapper overrides. |
testID | string | ghost-pull-refresh | Root identifier; refresh action adds -refresh. |
GhostPullRefreshRef.refresh(): Promise<void> resolves after the lifecycle, including dismissal. A touch interruption ends the pending simulated cycle without starting the request. The host owns cancellation of network requests and any state updates inside its onRefresh callback.
Compatibility
Built for Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, Gesture Handler 3.3, and Skia 2.14.0. These modules are included in the project's Expo Go stack. Supports iOS, Android, and web.
The wrapper is embeddable, uses its own measured width, and applies no safe area padding. The host supplies a bounded height and safe area insets. The demo limits width on tablets and scrolls its event feed in compact and landscape layouts.
Accessibility
Reduced motion removes particles, blur masks, bobbing, scale, stretching, and spring displacement; appearance and dismissal use 200 ms fades. The revealed character is a labeled refresh button with an activate action, and the imperative API supports an always-visible host refresh button (shown in the demo). List rows remain separate accessible content. Labels respect system font scaling.




