Skip to content
pixelfor Expo
Ctrl K

Gesture

Pull to Refresh

A resisted pull reveals a little face and triggers an asynchronous refresh

Try it on your phone

Pull to Refresh

Scan to open Pull to Refresh in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Be UI

Our Expo component

React Native Web

Component source

Make it yours

One file to copy into your app.

pull-to-refresh.tsx

import React, { useEffect, useState } from "react"
import { Text, View, type StyleProp, type ViewStyle } from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import Animated, {
  Easing,
  interpolate,
  useAnimatedProps,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withRepeat,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import { scheduleOnRN } from "react-native-worklets"
import Svg, { Circle, G, Path, Rect } from "react-native-svg"
const AG = Animated.createAnimatedComponent(G)
export interface PullToRefreshProps {
  children: React.ReactNode
  onRefresh: () => void | Promise<void>
  refreshing?: boolean
  disabled?: boolean
  threshold?: number
  maxPull?: number
  holdHeight?: number
  height?: number
  contentContainerStyle?: StyleProp<ViewStyle>
  backgroundColor?: string
  textColor?: string
  pullLabel?: string
  releaseLabel?: string
  refreshingLabel?: string
  testID?: string
}
export function PullToRefresh({
  children,
  onRefresh,
  refreshing,
  disabled = false,
  threshold = 76,
  maxPull = 132,
  holdHeight = 68,
  height = 340,
  contentContainerStyle,
  backgroundColor = "#1e1e1e",
  textColor = "#888",
  pullLabel = "Pull to refresh",
  releaseLabel = "Release to refresh",
  refreshingLabel = "Refreshing",
  testID,
}: PullToRefreshProps) {
  const [localBusy, setLocalBusy] = useState(false),
    [ready, setReady] = useState(false),
    busy = refreshing ?? localBusy
  const reduced = useReducedMotion(),
    offset = useSharedValue(0),
    scrollY = useSharedValue(0),
    active = useSharedValue(false),
    readyUI = useSharedValue(false),
    spin = useSharedValue(0)
  async function refresh() {
    if (busy) return
    setLocalBusy(true)
    try {
      await onRefresh()
    } finally {
      setLocalBusy(false)
      setReady(false)
      offset.value = withSpring(0, { damping: 40, stiffness: 420, mass: 0.5 })
    }
  }
  useEffect(() => {
    offset.value = reduced
      ? busy
        ? holdHeight
        : 0
      : withSpring(busy ? holdHeight : 0, { damping: 40, stiffness: 420, mass: 0.5 })
    spin.value =
      busy && !reduced ? withRepeat(withTiming(360, { duration: 900, easing: Easing.bezier(0.77, 0, 0.175, 1) }), -1) : 0
  }, [busy, holdHeight, reduced, offset, spin])
  const native = Gesture.Native()
  const pull = Gesture.Pan()
    .simultaneousWithExternalGesture(native)
    .activeOffsetY(8)
    .failOffsetX([-24, 24])
    .enabled(!disabled && !busy)
    .onStart(() => {
      active.value = scrollY.value <= 0
    })
    .onUpdate((e) => {
      if (!active.value) return
      offset.value =
        maxPull * (1 - Math.exp(-Math.max(0, e.translationY) / maxPull))
      const next = offset.value >= threshold
      if (next !== readyUI.value) {
        readyUI.value = next
        scheduleOnRN(setReady, next)
      }
    })
    .onEnd(() => {
      if (active.value && readyUI.value) {
        offset.value = withSpring(holdHeight, { damping: 40, stiffness: 420, mass: 0.5 })
        scheduleOnRN(refresh)
      } else offset.value = withSpring(0, { damping: 40, stiffness: 420, mass: 0.5 })
      readyUI.value = false
      active.value = false
    })
    .onFinalize(() => {
      if (active.value)
        offset.value = withSpring(0, { damping: 40, stiffness: 420, mass: 0.5 })
      active.value = false
    })
  const scrolled = useAnimatedScrollHandler((e) => {
    scrollY.value = e.contentOffset.y
  })
  const content = useAnimatedStyle(() => ({
    transform: [{ translateY: offset.value }],
  }))
  const indicator = useAnimatedStyle(() => ({
    opacity: interpolate(offset.value, [0, 10, threshold], [0, 0.45, 1], "clamp"),
    transform: [
      { scale: reduced ? 1 : interpolate(offset.value, [0, threshold], [0.86, 1], "clamp") },
    ],
  }))
  const buddy = useAnimatedStyle(() => {
    const p = Math.min(1, offset.value / threshold)
    return { transform: [{ translateY: reduced ? 0 : -7+7*p }, { rotate: `${reduced ? 0 : -10+10*p}deg` }, { scaleY: reduced ? 1 : interpolate(p, [0,.55,1], [.68,1.1,.92]) }] }
  })
  const orbit = useAnimatedProps(() => ({ transform: `translate(18 18) rotate(${busy ? spin.value : ready ? 0 : -35}) translate(-18 -18)` }))
  const eyes = useAnimatedProps(() => ({ transform: `translate(18 16) scale(1 ${busy && !reduced ? interpolate(spin.value,[0,90,180,270,360],[1,1,.15,1,1]) : ready ? 1.18 : 1}) translate(-18 -16)` }))
  return (
    <View
      testID={testID}
      style={{ height, borderRadius: 22, backgroundColor, overflow: "hidden" }}
    >
      <Animated.View
        pointerEvents="none"
        style={[
          {
            position: "absolute",
            top: 0,
            left: 0,
            right: 0,
            alignItems: "center",
            height: 68,
            justifyContent: "center",
            gap: 2,
          },
          indicator,
        ]}
      >
        <Animated.View style={buddy}>
          <Svg width={36} height={36} viewBox="0 0 36 36">
            <AG animatedProps={orbit} opacity={ready || busy ? 1 : 0}><Path d="M18 2.5a15.5 15.5 0 0 1 12.7 6.6" fill="none" stroke={textColor} strokeWidth={1.5} strokeLinecap="round" /><Circle cx={31.3} cy={10.2} r={2.2} fill="#fff" /></AG>
            <Rect x={7} y={7} width={22} height={22} rx={9} fill="#fff" />
            <AG animatedProps={eyes}><Circle cx={14.2} cy={16} r={1.45} fill="#141414" /><Circle cx={21.8} cy={16} r={1.45} fill="#141414" /></AG>
            <Path d={ready ? "M14 20.5c1 2.4 7 2.4 8 0" : "M14.5 21h7"} fill="none" stroke="#141414" strokeWidth={1.5} strokeLinecap="round" opacity={busy ? 0 : 1} />
            {busy && <Circle cx={18} cy={21} r={1.6} fill="#141414" />}
          </Svg>
        </Animated.View>
        <Text
          accessibilityLiveRegion="polite"
          style={{ color: textColor, fontSize: 11 }}
        >
          {busy ? refreshingLabel : ready ? releaseLabel : pullLabel}
        </Text>
      </Animated.View>
      <GestureDetector gesture={pull}>
        <Animated.View style={[{ flex: 1 }, content]}>
          <GestureDetector gesture={native}>
            <Animated.ScrollView
              bounces={false}
              overScrollMode="never"
              onScroll={scrolled}
              scrollEventThrottle={16}
              showsVerticalScrollIndicator={false}
              contentContainerStyle={[{
                padding: 20,
                paddingBottom: maxPull + 20,
              }, contentContainerStyle]}
              accessibilityActions={[
                { name: "refresh", label: "Refresh content" },
              ]}
              onAccessibilityAction={(e) => {
                if (
                  e.nativeEvent.actionName === "refresh" &&
                  !busy &&
                  !disabled
                )
                  void refresh()
              }}
            >
              {children}
            </Animated.ScrollView>
          </GestureDetector>
        </Animated.View>
      </GestureDetector>
    </View>
  )
}

Give the gestures and motion a run on a real Android phone.

Back to Be UI