Shimmer

A loading placeholder that shows animated shimmer or pulse effect

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated expo-linear-gradient

Copy and paste the following code into your project. component/molecules/shimmer

import { LinearGradient } from "expo-linear-gradient";import React, { useCallback, useEffect, useState, useRef, memo } from "react";import {  Animated,  View,  type LayoutRectangle,  type LayoutChangeEvent,} from "react-native";import { Easing } from "react-native-reanimated";import type { IShimmerEffect, IShimmerGroup } from "./Shimmer.types";import { SHIMMER_PRESETS } from "./const";export const ShimmerEffect: React.FC<IShimmerEffect> &  React.FunctionComponent<IShimmerEffect> = memo<IShimmerEffect>(  ({    isLoading = true,    shimmerColors,    duration = 1500,    className,    style,    variant = "shimmer",    direction = "leftToRight",    preset = "dark",    opacity = 1,    children,  }: IShimmerEffect):    | (React.ReactNode & React.JSX.Element & React.ReactElement)    | null => {    const [layout, setLayout] = useState<LayoutRectangle | null>(null);    const shimmerAnim = useRef<Animated.Value>(new Animated.Value(0)).current;    const pulseAnim = useRef<Animated.Value>(new Animated.Value(0.3)).current;    const fadeAnim = useRef<Animated.Value>(new Animated.Value(0)).current;    const themeColors =      preset === "custom" && shimmerColors        ? shimmerColors        : SHIMMER_PRESETS[preset].colors;    const backgroundColor =      preset !== "custom" ? SHIMMER_PRESETS[preset].backgroundColor : undefined;    const onLayout = useCallback((e: LayoutChangeEvent) => {      setLayout(e.nativeEvent.layout);    }, []);    useEffect(() => {      if (!layout) return;      if (isLoading) {        fadeAnim.setValue(0);        if (variant === "shimmer") {          shimmerAnim.setValue(0);          Animated.loop(            Animated.timing(shimmerAnim, {              toValue: 1,              duration,              easing: Easing.linear,              useNativeDriver: true,            }),          ).start();        } else {          Animated.loop(            Animated.sequence([              Animated.timing(pulseAnim, {                toValue: 1,                duration: duration / 2,                easing: Easing.ease,                useNativeDriver: true,              }),              Animated.timing(pulseAnim, {                toValue: 0.3,                duration: duration / 2,                easing: Easing.ease,                useNativeDriver: true,              }),            ]),          ).start();        }      } else {        shimmerAnim.stopAnimation();        pulseAnim.stopAnimation();        shimmerAnim.setValue(0);        pulseAnim.setValue(0.3);        Animated.timing(fadeAnim, {          toValue: 1,          duration: 400,          easing: Easing.out(Easing.ease),          useNativeDriver: true,        }).start();      }      return () => {        shimmerAnim.stopAnimation();        pulseAnim.stopAnimation();      };    }, [      layout,      isLoading,      duration,      variant,      shimmerAnim,      pulseAnim,      fadeAnim,    ]);    const getWaveWidth = () => {      if (!layout) return 0;      if (direction === "leftToRight" || direction === "rightToLeft") {        return layout.width * 0.5;      }      return layout.height * 0.5;    };    const waveWidth = getWaveWidth();    const getTransform = () => {      if (!layout) return {};      if (variant === "pulse") {        return { opacity: pulseAnim };      }      switch (direction) {        case "leftToRight":          return {            transform: [              {                translateX: shimmerAnim.interpolate<number>({                  inputRange: [0, 1],                  outputRange: [-waveWidth, layout.width + waveWidth],                }),              },            ],          };        case "rightToLeft":          return {            transform: [              {                translateX: shimmerAnim.interpolate({                  inputRange: [0, 1],                  outputRange: [layout.width + waveWidth, -waveWidth],                }),              },            ],          };        case "topToBottom":          return {            transform: [              {                translateY: shimmerAnim.interpolate<number>({                  inputRange: [0, 1],                  outputRange: [-waveWidth, layout.height + waveWidth],                }),              },            ],          };        case "bottomToTop":          return {            transform: [              {                translateY: shimmerAnim.interpolate<number>({                  inputRange: [0, 1],                  outputRange: [layout.height + waveWidth, -waveWidth],                }),              },            ],          };        default:          return {};      }    };    return (      <View        onLayout={onLayout}        className={className}        style={[          style,          {            overflow: "hidden",            backgroundColor: isLoading              ? backgroundColor || style?.backgroundColor              : style?.backgroundColor || "transparent",            opacity,          },        ]}      >        {!isLoading && (          <Animated.View            style={{              opacity: fadeAnim,            }}          >            {children}          </Animated.View>        )}        {isLoading && layout && (          <View            style={{              position: "absolute",              top: 0,              left: 0,              right: 0,              bottom: 0,              overflow: "hidden",            }}            pointerEvents="none"          >            <Animated.View              style={[                {                  width:                    variant === "shimmer" &&                    (direction === "leftToRight" || direction === "rightToLeft")                      ? waveWidth                      : layout.width,                  height:                    variant === "shimmer" &&                    (direction === "topToBottom" || direction === "bottomToTop")                      ? waveWidth                      : layout.height,                },                getTransform(),              ]}            >              {variant === "shimmer" ? (                <LinearGradient                  colors={themeColors as [string, string, ...string[]]}                  start={                    direction === "leftToRight" || direction === "rightToLeft"                      ? { x: 0, y: 0.5 }                      : { x: 0.5, y: 0 }                  }                  end={                    direction === "leftToRight" || direction === "rightToLeft"                      ? { x: 1, y: 0.5 }                      : { x: 0.5, y: 1 }                  }                  style={{ flex: 1 }}                />              ) : (                <View                  style={{                    flex: 1,                    backgroundColor: themeColors[1],                  }}                />              )}            </Animated.View>          </View>        )}      </View>    );  },);export const ShimmerGroup: React.FC<IShimmerGroup> &  React.FunctionComponent<IShimmerGroup> = memo<IShimmerGroup>(  ({    isLoading = true,    children,    preset = "dark",    duration = 1500,    direction = "leftToRight",    opacity = 1,  }: IShimmerGroup):    | (React.JSX.Element & React.ReactNode & React.ReactElement)    | null => {    const propagateProps = (children: React.ReactNode): React.ReactNode => {      return React.Children.map(children, (child) => {        if (!React.isValidElement(child)) {          return child;        }        if (child.type === Shimmer || child.type === ShimmerEffect) {          return React.cloneElement(child as React.ReactElement<any>, {            isLoading,            preset: child.props.preset || preset,            duration: child.props.duration || duration,            direction: child.props.direction || direction,            opacity: child.props.opacity ?? opacity,          });        }        if (child.props && child.props.children) {          return React.cloneElement(child as React.ReactElement<any>, {            children: propagateProps(child.props.children),          });        }        return child;      });    };    return <>{propagateProps(children)}</>;  },);export const Shimmer: React.FC<IShimmerEffect> &  React.FunctionComponent<IShimmerEffect> = memo<IShimmerEffect>(  (    props: IShimmerEffect,  ): (React.ReactNode & React.JSX.Element & React.ReactElement) | null => {    return <ShimmerEffect {...props} />;  },);

Usage

import { View, Text, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import { ShimmerGroup, Shimmer } from "@/components";import { useState, useEffect } from "react";export default function App(_$_: Record<string, unknown>) {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),  });  const [isLoading, setIsLoading] = useState<boolean>(true);  useEffect(() => {    const timer = setTimeout(() => {      setIsLoading(false);    }, 2000);    return () => clearTimeout(timer);  }, []);  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <View style={styles.header}>        <Text          style={[            styles.title,            { fontFamily: fontLoaded ? "HelveticaNowDisplay" : undefined },          ]}        >          Profile        </Text>        <View style={styles.iconButton}>          <SymbolView name="gear" size={20} tintColor="#fff" />        </View>      </View>      <View style={styles.profileCard}>        <ShimmerGroup          isLoading={isLoading}          preset="dark"          duration={1000}          direction="leftToRight"        >          <Shimmer style={styles.avatar}>            <View style={styles.avatarPlaceholder}>              <SymbolView name="person.fill" size={32} tintColor="#666" />            </View>          </Shimmer>          <Shimmer style={styles.nameSkeleton}>            <Text              style={[                styles.name,                {                  fontFamily: fontLoaded ? "HelveticaNowDisplay" : undefined,                },              ]}            >              Rit3zh            </Text>          </Shimmer>          <Shimmer style={styles.usernameSkeleton}>            <Text              style={[                styles.username,                { fontFamily: fontLoaded ? "SfProRounded" : undefined },              ]}            >              @rit3zh            </Text>          </Shimmer>          <View style={styles.statsRow}>            <Shimmer style={styles.statBox}>              <View style={styles.statContent}>                <Text                  style={[                    styles.statNumber,                    {                      fontFamily: fontLoaded                        ? "HelveticaNowDisplay"                        : undefined,                    },                  ]}                >                  124                </Text>                <Text                  style={[                    styles.statLabel,                    {                      fontFamily: fontLoaded ? "SfProRounded" : undefined,                    },                  ]}                >                  Posts                </Text>              </View>            </Shimmer>            <Shimmer style={styles.statBox}>              <View style={styles.statContent}>                <Text                  style={[                    styles.statNumber,                    {                      fontFamily: fontLoaded                        ? "HelveticaNowDisplay"                        : undefined,                    },                  ]}                >                  1.2K                </Text>                <Text                  style={[                    styles.statLabel,                    {                      fontFamily: fontLoaded ? "SfProRounded" : undefined,                    },                  ]}                >                  Followers                </Text>              </View>            </Shimmer>            <Shimmer style={styles.statBox}>              <View style={styles.statContent}>                <Text                  style={[                    styles.statNumber,                    {                      fontFamily: fontLoaded                        ? "HelveticaNowDisplay"                        : undefined,                    },                  ]}                >                  856                </Text>                <Text                  style={[                    styles.statLabel,                    {                      fontFamily: fontLoaded ? "SfProRounded" : undefined,                    },                  ]}                >                  Following                </Text>              </View>            </Shimmer>          </View>          <Shimmer style={styles.buttonSkeleton}>            <View style={styles.buttonInner}>              <Text                style={[                  styles.buttonText,                  { fontFamily: fontLoaded ? "SfProRounded" : undefined },                ]}              >                Edit Profile              </Text>            </View>          </Shimmer>        </ShimmerGroup>      </View>      <Text        style={[          styles.infoText,          { fontFamily: fontLoaded ? "SfProRounded" : undefined },        ]}      >        {isLoading ? "Loading profile..." : "Profile loaded!"}      </Text>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",  },  header: {    flexDirection: "row",    justifyContent: "space-between",    alignItems: "center",    paddingHorizontal: 24,    paddingTop: 60,    paddingBottom: 30,  },  title: {    fontSize: 28,    fontWeight: "600",    color: "#fff",  },  iconButton: {    width: 40,    height: 40,    borderRadius: 20,    backgroundColor: "rgba(255,255,255,0.08)",    justifyContent: "center",    alignItems: "center",  },  profileCard: {    marginHorizontal: 24,    backgroundColor: "#141414",    borderRadius: 24,    padding: 32,    alignItems: "center",    gap: 16,  },  avatar: {    width: 100,    height: 100,    borderRadius: 50,    backgroundColor: "#1c1c1c",    justifyContent: "center",    alignItems: "center",  },  avatarPlaceholder: {    justifyContent: "center",    alignItems: "center",  },  nameSkeleton: {    width: 180,    height: 28,    borderRadius: 8,    justifyContent: "center",    alignItems: "center",  },  name: {    fontSize: 22,    fontWeight: "600",    color: "#fff",  },  usernameSkeleton: {    width: 120,    height: 18,    borderRadius: 6,    justifyContent: "center",    alignItems: "center",    marginTop: -8,  },  username: {    fontSize: 14,    color: "#888",  },  statsRow: {    flexDirection: "row",    gap: 12,    marginTop: 12,    width: "100%",  },  statBox: {    flex: 1,    height: 70,    borderRadius: 16,    backgroundColor: "#1c1c1c",    justifyContent: "center",    alignItems: "center",  },  statContent: {    alignItems: "center",    gap: 4,  },  statNumber: {    fontSize: 20,    fontWeight: "700",    color: "#fff",  },  statLabel: {    fontSize: 11,    color: "#666",  },  buttonSkeleton: {    width: "100%",    height: 48,    borderRadius: 24,    backgroundColor: "#1c1c1c",    marginTop: 8,    overflow: "hidden",  },  buttonInner: {    width: "100%",    height: "100%",    backgroundColor: "#fff",    borderRadius: 24,    justifyContent: "center",    alignItems: "center",  },  buttonText: {    fontSize: 15,    fontWeight: "600",    color: "#000",  },  infoText: {    fontSize: 13,    color: "#666",    textAlign: "center",    marginTop: 24,  },});

Props

IShimmerGroup

React Native Reanimated
Expo Linear Gradient