Veltrix

loading

Pull Refresh Morph

A scrollable-agnostic pull-to-refresh adapter with threshold morphing, native refresh semantics, and explicit completion states.

AppleiOSAndroidAndroidExpoExpo Go
reanimatedsvg
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add react-native-reanimated react-native-svg

Anatomy

1 file

anatomy.tsx
<PullRefreshMorph>  <PullRefreshMorph.Indicator /></PullRefreshMorph>

Custom indicator

Render a custom indicator from the pull state exposed by PullRefreshMorph.Indicator:

CustomIndicatorExample.tsx
import { 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-variants

Usage

PullRefreshMorphExample.tsx
import { 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>  );}

Component files

6 files

pull-refresh-morph/config.ts
import 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.tsx
import { 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.ts
import 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.tsx
import {  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.ts
import { 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.ts
import { 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,  };}

API reference

Generated directly from the exported component props.

PullRefreshMorph

Adds pull-to-refresh behavior to an animated scrollable.

PullRefreshMorph.Indicator

Renders a custom indicator.