Shimmer
A loading placeholder that shows animated shimmer or pulse effect
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated expo-linear-gradientCopy 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
