loading
Pull Refresh Morph
A scrollable-agnostic pull-to-refresh adapter with threshold morphing, native refresh semantics, and explicit completion states.
iOSAndroidExpo Go
reanimatedsvg
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimated react-native-svganatomy.tsx<PullRefreshMorph> <PullRefreshMorph.Indicator /></PullRefreshMorph>Custom indicator
Render a custom indicator from the pull state exposed by
PullRefreshMorph.Indicator:
CustomIndicatorExample.tsximport { Text } from "react-native";import Animated from "react-native-reanimated";export function CustomIndicatorExample() { async function refresh() { await new Promise((resolve) => setTimeout(resolve, 800)); } return ( <PullRefreshMorph onRefresh={refresh}> <Animated.ScrollView alwaysBounceVertical> <Text>Pull down to refresh</Text> </Animated.ScrollView> <PullRefreshMorph.Indicator> {({ state }) => <Text>{state === "armed" ? "Release" : "Pull"}</Text>} </PullRefreshMorph.Indicator> </PullRefreshMorph> );}Install tailwind variants
pnpm add tailwind-variantsUsage
PullRefreshMorphExample.tsximport { Text, View } from "react-native";import Animated from "react-native-reanimated";import { PullRefreshMorph } from "@animations/ui/components/loading/pull-refresh-morph/uniwind";const updates = [ { id: "motion", title: "Motion tokens updated" }, { id: "components", title: "Three components added" },];export function PullRefreshMorphExample() { async function refresh() { await new Promise((resolve) => setTimeout(resolve, 800)); } return ( <View className="h-80 overflow-hidden rounded-3xl"> <PullRefreshMorph className="rounded-3xl" classNames={{ indicatorSurface: "border-neutral-300", indicatorLabel: "text-neutral-950", }} indicatorColors={{ ringArmed: "#171713", ringProgress: "#6f5cff", ringTrack: "#d9d5ff", }} onRefresh={refresh} > <Animated.FlatList alwaysBounceVertical data={updates} keyExtractor={(item) => item.id} renderItem={({ item }) => ( <Text className="px-4 py-3">{item.title}</Text> )} /> </PullRefreshMorph> </View> );}pull-refresh-morph/config.tsimport type { PullRefreshMorphIndicatorColors, PullRefreshMorphState,} from "./types";export const PULL_REFRESH_MORPH_CONFIG = { threshold: 72, maximumPullDistance: 112, resultHoldDuration: 700, settleDuration: 280,} as const;export const PULL_REFRESH_MORPH_INDICATOR_COLORS = { armedBackground: "#cfff45", armedForeground: "#171713", border: "#e4e3dc", errorBackground: "#e7513b", errorForeground: "#ffffff", label: "#171713", pullBackground: "#efecff", pullForeground: "#6f5cff", refreshingBackground: "#6f5cff", refreshingForeground: "#ffffff", ringArmed: "#171713", ringProgress: "#6f5cff", ringTrack: "#d9d5ff", successBackground: "#cfff45", successForeground: "#171713", surface: "#ffffff",} satisfies PullRefreshMorphIndicatorColors;export const PULL_REFRESH_MORPH_INDICATOR_CONTENT = { armed: { background: "armedBackground", foreground: "armedForeground", icon: "arrow-up", label: "Release", ring: "ringArmed", showsProgress: true, }, error: { background: "errorBackground", foreground: "errorForeground", icon: "x", label: "Try again", ring: "ringProgress", showsProgress: false, }, idle: { background: "pullBackground", foreground: "pullForeground", icon: "arrow-down", label: "Pull to update", ring: "ringProgress", showsProgress: true, }, pulling: { background: "pullBackground", foreground: "pullForeground", icon: "arrow-down", label: "Pull to update", ring: "ringProgress", showsProgress: true, }, refreshing: { background: "refreshingBackground", foreground: "refreshingForeground", icon: "spinner", label: "Updating", ring: "ringProgress", showsProgress: false, }, success: { background: "successBackground", foreground: "successForeground", icon: "check", label: "Updated", ring: "ringProgress", showsProgress: false, },} as const satisfies Record< PullRefreshMorphState, { background: keyof PullRefreshMorphIndicatorColors; foreground: keyof PullRefreshMorphIndicatorColors; icon: | "arrow-down" | "arrow-up" | "check" | "spinner" | "x"; label: string; ring: "ringArmed" | "ringProgress"; showsProgress: boolean; }>;pull-refresh-morph/indicator-primitives.tsximport { StyleSheet } from "react-native";import { Feather } from "@expo/vector-icons";import Svg, { Circle } from "react-native-svg";import Animated, { Easing, useAnimatedProps, useAnimatedStyle, useDerivedValue, withRepeat, withTiming, type SharedValue,} from "react-native-reanimated";const RING_SIZE = 32;const RING_RADIUS = 14;const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;const AnimatedCircle = Animated.createAnimatedComponent(Circle);export function PullRefreshMorphProgressRing({ progressColor, progress, trackColor,}: { progressColor: string; progress: SharedValue<number>; trackColor: string;}) { const animatedProps = useAnimatedProps(() => ({ strokeDashoffset: RING_CIRCUMFERENCE * (1 - progress.get()), })); return ( <Svg height={RING_SIZE} pointerEvents="none" style={styles.ring} viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`} width={RING_SIZE} > <Circle cx={RING_SIZE / 2} cy={RING_SIZE / 2} fill="none" r={RING_RADIUS} stroke={trackColor} strokeWidth={2} /> <AnimatedCircle animatedProps={animatedProps} cx={RING_SIZE / 2} cy={RING_SIZE / 2} fill="none" r={RING_RADIUS} rotation={-90} origin={`${RING_SIZE / 2}, ${RING_SIZE / 2}`} stroke={progressColor} strokeDasharray={`${RING_CIRCUMFERENCE} ${RING_CIRCUMFERENCE}`} strokeLinecap="round" strokeWidth={2.5} /> </Svg> );}export function PullRefreshMorphSpinner({ color,}: { color: string;}) { const rotation = useDerivedValue(() => withRepeat( withTiming(360, { duration: 760, easing: Easing.linear, }), -1, false, ), ); const animatedStyle = useAnimatedStyle(() => ({ transform: [ { rotate: `${rotation.get()}deg`, }, ], })); return ( <Animated.View style={animatedStyle}> <Feather color={color} name="loader" size={16} /> </Animated.View> );}export function PullRefreshMorphIndicatorIcon({ color, icon,}: { color: string; icon: | "arrow-down" | "arrow-up" | "check" | "spinner" | "x";}) { if (icon === "spinner") { return <PullRefreshMorphSpinner color={color} />; } return <Feather color={color} name={icon} size={16} />;}const styles = StyleSheet.create({ ring: { bottom: 0, left: 0, position: "absolute", right: 0, top: 0, },});pull-refresh-morph/types.tsimport type { ReactElement, ReactNode,} from "react";import type { RefreshControlProps,} from "react-native";import type { ScrollHandlerProcessed, SharedValue,} from "react-native-reanimated";export const PULL_REFRESH_MORPH_STATES = [ "idle", "pulling", "armed", "refreshing", "success", "error",] as const;export type PullRefreshMorphState = (typeof PULL_REFRESH_MORPH_STATES)[number];export type PullRefreshMorphRenderState = { progress: SharedValue<number>; pullDistance: SharedValue<number>; state: PullRefreshMorphState;};export type PullRefreshMorphIndicatorProps = { /** Render a custom indicator from the current pull state. */ children: ( state: PullRefreshMorphRenderState, ) => ReactNode;};export type PullRefreshMorphIndicatorColors = { armedBackground: string; armedForeground: string; border: string; errorBackground: string; errorForeground: string; label: string; pullBackground: string; pullForeground: string; refreshingBackground: string; refreshingForeground: string; ringArmed: string; ringProgress: string; ringTrack: string; successBackground: string; successForeground: string; surface: string;};export type PullRefreshMorphScrollProps = { onScroll: ScrollHandlerProcessed; refreshControl: ReactElement<RefreshControlProps>; scrollEventThrottle: number;};export type PullRefreshMorphBaseProps = { /** Animated scrollable child that receives refresh behavior. */ children: ReactNode; disabled?: boolean; indicatorColors?: Partial<PullRefreshMorphIndicatorColors>; maximumPullDistance?: number; onRefresh: () => Promise<void> | void; onStateChange?: ( state: PullRefreshMorphState, ) => void; threshold?: number;};pull-refresh-morph/uniwind/index.tsximport { Children, cloneElement, createContext, isValidElement, useContext, type ReactElement, type ReactNode,} from "react";import { RefreshControl, Text, View,} from "react-native";import Animated, { LinearTransition, useAnimatedStyle,} from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import { PULL_REFRESH_MORPH_INDICATOR_COLORS, PULL_REFRESH_MORPH_INDICATOR_CONTENT,} from "../config";import { PullRefreshMorphIndicatorIcon, PullRefreshMorphProgressRing,} from "../indicator-primitives";import type { PullRefreshMorphBaseProps, PullRefreshMorphIndicatorProps, PullRefreshMorphIndicatorColors, PullRefreshMorphRenderState, PullRefreshMorphScrollProps, PullRefreshMorphState,} from "../types";import { usePullRefreshMorph } from "../use-pull-refresh-morph";import { pullRefreshMorphVariants, type PullRefreshMorphSlot,} from "./variants";export type { PullRefreshMorphSlot } from "./variants";export type PullRefreshMorphProps = PullRefreshMorphBaseProps & { className?: string; classNames?: SlotsToClasses<PullRefreshMorphSlot>; };function PullRefreshMorphDefaultIndicator({ classNames, colors: colorOverrides, progress, state,}: { classNames?: SlotsToClasses<PullRefreshMorphSlot>; colors?: Partial<PullRefreshMorphIndicatorColors>; progress: PullRefreshMorphRenderState["progress"]; state: PullRefreshMorphState;}) { const colors = { ...PULL_REFRESH_MORPH_INDICATOR_COLORS, ...colorOverrides, }; const content = PULL_REFRESH_MORPH_INDICATOR_CONTENT[state]; const { indicatorIcon, indicatorLabel, indicatorSurface, } = pullRefreshMorphVariants({ state }); return ( <Animated.View className={indicatorSurface({ className: classNames?.indicatorSurface, })} layout={LinearTransition.duration(180)} style={ colorOverrides && { backgroundColor: colorOverrides.surface, borderColor: colorOverrides.border, } } > <Animated.View className={indicatorIcon({ className: classNames?.indicatorIcon, })} layout={LinearTransition.duration(180)} style={ colorOverrides?.[content.background] && { backgroundColor: colorOverrides[content.background], } } > {content.showsProgress && ( <PullRefreshMorphProgressRing progress={progress} progressColor={colors[content.ring]} trackColor={colors.ringTrack} /> )} <PullRefreshMorphIndicatorIcon color={colors[content.foreground]} icon={content.icon} /> </Animated.View> <Animated.Text className={indicatorLabel({ className: classNames?.indicatorLabel, })} style={ colorOverrides?.label && { color: colorOverrides.label, } } > {content.label} </Animated.Text> </Animated.View> );}const PullRefreshMorphIndicatorContext = createContext<PullRefreshMorphRenderState | null>(null);/** Renders a custom indicator. */function PullRefreshMorphIndicator( { children }: PullRefreshMorphIndicatorProps,) { const state = useContext(PullRefreshMorphIndicatorContext); if (!state) { throw new Error( "PullRefreshMorph.Indicator must be used inside PullRefreshMorph.", ); } return children(state);}/** Adds pull-to-refresh behavior to an animated scrollable. */function PullRefreshMorphRoot({ children, className, classNames, disabled, indicatorColors, maximumPullDistance, onRefresh, onStateChange, threshold,}: PullRefreshMorphProps) { const childNodes = Children.toArray(children); const indicator = childNodes.find( (child) => isValidElement(child) && child.type === PullRefreshMorphIndicator, ); const scrollable = childNodes.find( (child): child is ReactElement => isValidElement(child) && child.type !== PullRefreshMorphIndicator, ); const controller = usePullRefreshMorph({ disabled, maximumPullDistance, onRefresh, onStateChange, threshold, }); const progress = controller.progress; const pullDistance = controller.pullDistance; const { indicator: indicatorSlot, root } = pullRefreshMorphVariants({ state: controller.state, }); const indicatorStyle = useAnimatedStyle(() => ({ opacity: progress.get(), transform: [ { translateY: pullDistance.get(), }, ], })); const scrollProps = { onScroll: controller.onScroll, refreshControl: ( <RefreshControl colors={["transparent"]} enabled={!disabled} onRefresh={controller.refresh} progressBackgroundColor="transparent" refreshing={ controller.state === "refreshing" } tintColor="transparent" /> ), scrollEventThrottle: 16, } satisfies PullRefreshMorphScrollProps; return ( <View className={root({ className: [className, classNames?.root], })} > <Animated.View className={indicatorSlot({ className: classNames?.indicator, })} pointerEvents="none" style={indicatorStyle} > {isValidElement<PullRefreshMorphIndicatorProps>(indicator) ? ( <PullRefreshMorphIndicatorContext.Provider value={{ progress, pullDistance, state: controller.state, }} > {indicator} </PullRefreshMorphIndicatorContext.Provider> ) : ( <PullRefreshMorphDefaultIndicator classNames={classNames} colors={indicatorColors} progress={progress} state={controller.state} /> )} </Animated.View> {scrollable && cloneElement(scrollable, scrollProps)} </View> );}export const PullRefreshMorph = Object.assign( PullRefreshMorphRoot, { Indicator: PullRefreshMorphIndicator, },);pull-refresh-morph/uniwind/variants.tsimport { tv } from "tailwind-variants";export const pullRefreshMorphSlots = { indicator: "absolute -top-12 inset-x-0 z-10 items-center", indicatorIcon: "size-8 items-center justify-center rounded-full", indicatorLabel: "text-xs font-extrabold text-[#171713]", indicatorSurface: "flex-row items-center gap-2 rounded-full border border-[#e4e3dc] bg-white py-1 pr-3 pl-1", root: "flex-1 overflow-hidden",} as const;export type PullRefreshMorphSlot = keyof typeof pullRefreshMorphSlots;export const pullRefreshMorphVariants = tv({ slots: pullRefreshMorphSlots, variants: { state: { armed: { indicatorIcon: "bg-[#cfff45]", }, error: { indicatorIcon: "bg-[#e7513b]", }, idle: { indicatorIcon: "bg-[#efecff]", }, pulling: { indicatorIcon: "bg-[#efecff]", }, refreshing: { indicatorIcon: "bg-[#6f5cff]", }, success: { indicatorIcon: "bg-[#cfff45]", }, }, },});pull-refresh-morph/use-pull-refresh-morph.tsimport { useCallback, useState } from "react";import { clamp, runOnJS, useAnimatedScrollHandler, useDerivedValue, useSharedValue, withDelay, withTiming,} from "react-native-reanimated";import { PULL_REFRESH_MORPH_CONFIG } from "./config";import type { PullRefreshMorphBaseProps, PullRefreshMorphState,} from "./types";type Options = Omit< PullRefreshMorphBaseProps, "children" | "indicator">;export function usePullRefreshMorph({ disabled = false, maximumPullDistance = PULL_REFRESH_MORPH_CONFIG.maximumPullDistance, onRefresh, onStateChange, threshold = PULL_REFRESH_MORPH_CONFIG.threshold,}: Options) { const [state, setState] = useState<PullRefreshMorphState>("idle"); const pullDistance = useSharedValue(0); const refreshing = useSharedValue(false); const armed = useSharedValue(false); const pulling = useSharedValue(false); const settling = useSharedValue(false); const progress = useDerivedValue(() => clamp(pullDistance.get() / threshold, 0, 1), ); const changeState = useCallback( (nextState: PullRefreshMorphState) => { setState(nextState); onStateChange?.(nextState); }, [onStateChange], ); const refresh = useCallback(async () => { if (disabled || refreshing.get()) { return; } refreshing.set(true); pulling.set(false); armed.set(false); changeState("refreshing"); try { await onRefresh(); changeState("success"); } catch { changeState("error"); } finally { refreshing.set(false); settling.set(true); pullDistance.set( withDelay( PULL_REFRESH_MORPH_CONFIG.resultHoldDuration, withTiming( 0, { duration: PULL_REFRESH_MORPH_CONFIG.settleDuration, }, (finished) => { if (finished) { settling.set(false); runOnJS(changeState)("idle"); } }, ), ), ); } }, [ changeState, disabled, onRefresh, armed, pullDistance, pulling, refreshing, settling, ]); const onScroll = useAnimatedScrollHandler({ onScroll(event) { const offsetY = event.contentOffset.y; if ( disabled || refreshing.get() || settling.get() ) { return; } const distance = clamp( -offsetY, 0, maximumPullDistance, ); pullDistance.set(distance); if (distance <= 0) { if (pulling.get()) { pulling.set(false); armed.set(false); runOnJS(changeState)("idle"); } return; } if (!pulling.get()) { pulling.set(true); runOnJS(changeState)("pulling"); } const nextArmed = distance >= threshold; if (armed.get() !== nextArmed) { armed.set(nextArmed); runOnJS(changeState)( nextArmed ? "armed" : "pulling", ); } }, }); return { progress, pullDistance, refresh, state, onScroll, };}PullRefreshMorph
Adds pull-to-refresh behavior to an animated scrollable.
PullRefreshMorph.Indicator
Renders a custom indicator.
