Skeleton

Displays an animated placeholder matching its dimensions while loading, revealing children when done.

react-nativeskeletonloadingplaceholderanimationreanimatedui-component

Installation

Step 1: Install Dependencies

npx expo install expo-linear-gradient

Step 2: Create the Skeleton Component

Create a new file (e.g., src/components/Skeleton.tsx) and copy the following code:

Skeleton.tsx
import React, { ReactNode, useEffect } from "react";
import {
  View,
  StyleSheet,
  StyleProp,
  ViewStyle,
  LayoutChangeEvent,
} from "react-native";
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withRepeat,
  withTiming,
  interpolate,
  cancelAnimation,
  Easing,
  ReduceMotion,
} from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient";

type SkeletonProps = {
  isLoading: boolean;
  baseColor: string; // Background color of the skeleton
  shimmerColor: string; // Color of the moving shimmer highlight
  children?: ReactNode;
  style: StyleProp<ViewStyle>;
  duration?: number;
  delay?: number;
  reduceMotion?: "always" | "never" | "system";
}

const GRADIENT_WIDTH_PERCENTAGE = 1; //how wide you want the gradient to be

const Skeleton: React.FC<SkeletonProps> = ({
  isLoading,
  children,
  baseColor,
  shimmerColor,
  style,
  duration = 1000,
  reduceMotion = 'system'
}) => {
  const sharedValue = useSharedValue(0);
  const componentWidth = useSharedValue(0);


  const motion =
  	reduceMotion === "never"
  		? ReduceMotion.Never
  		: reduceMotion === "always"
  			? ReduceMotion.Always
  			: ReduceMotion.System;

  useEffect(() => {
  	if (isLoading) {
  		// const effectiveDuration =
  		// 	duration ??
  		// 	Math.max(1000, componentWidth.value * ANIMATION_SPEED_FACTOR);

  		sharedValue.value = 0; // Reset before starting
  		sharedValue.value = withRepeat(
  			withTiming(1, {
  				duration: duration,
  				easing: Easing.linear,
  				reduceMotion: motion,
  			}),
  			-1,
  			false,
  			() => {},
  			motion
  		);
  	} else {
  		// Cancel animation if not loading
  		cancelAnimation(sharedValue);
  		sharedValue.value = 0;
  	}

  	// Cleanup
  	return () => cancelAnimation(sharedValue);
  }, [isLoading, sharedValue]);

  const animatedStyle = useAnimatedStyle(() => {
  	const gradientWidth = componentWidth.value * GRADIENT_WIDTH_PERCENTAGE;
  	const translateX = interpolate(
  		sharedValue.value,
  		[0, 1],
  		[-gradientWidth, componentWidth.value]
  	);

  	// Control opacity based on measurement *within the animated style*
  	const opacity = componentWidth.value > 0 ? 1 : 0;

  	return {
  		opacity: opacity,
  		transform: [{ translateX }],
  		width: gradientWidth,
  	};
  });

  //calculate the view layout
  const handleLayout = (event: LayoutChangeEvent) => {
  	const width = event.nativeEvent.layout.width;
  	componentWidth.value = width;
  };

  return isLoading ? (
  	<View
  		style={[styles.container, { backgroundColor: baseColor }, style]}
  		onLayout={handleLayout} // Measure the width
  	>
  		<Animated.View
  			style={[
  				StyleSheet.absoluteFill,
  				styles.gradientContainer,
  				animatedStyle,
  			]}
  		>
  			<LinearGradient
  				colors={[baseColor, shimmerColor, baseColor]}
  				start={{ x: 0, y: 0.5 }}
  				end={{ x: 1, y: 0.5 }}
  				style={styles.gradient}
  			/>
  		</Animated.View>
  	</View>
  ) : (
  	children ? <>{children}</> : null
  );
};

const styles = StyleSheet.create({
  container: {
  	overflow: "hidden",
  	position: "relative",
  },
  gradientContainer: {
  	position: "absolute",
  	top: 0,
  	bottom: 0,
  	left: 0,
  },
  gradient: {
  	flex: 1,
  },
});

export default Skeleton;

Usage

SkeletonPage.tsx
import React, { useState, useEffect } from "react";
import { View, Text, StyleSheet, Button, Image } from "react-native";
import Skeleton from "@/components/ui/Skeleton";
import { useAppColors } from "@/hooks/useAppColors";
import { SafeAreaView } from "react-native-safe-area-context";

const AVATAR_SIZE = 50;

export default function SkeletonPage() {
  const [isLoading, setIsLoading] = useState(true);
  const colors = useAppColors();

  useEffect(() => {
  	// Simulate data fetching only if currently loading replace with your own promise
  	if (isLoading) {
  		const timer = setTimeout(() => {
  			setIsLoading(false);
  		}, 3500);

  		return () => clearTimeout(timer);
  	}
  }, [isLoading]);

  const triggerReload = () => {
  	setIsLoading(true);
  };

  // const baseColor = colors.Neutral50;
  // const shimmerColor = colors.Neutral70;

  const avatarUrl = "https://picsum.photos/100";
  const name = "Walid Memon";
  const description = "Software Developer | React Native Enthusiast";

  return (
  	<SafeAreaView style={styles.screen}>
  		{/* Avatar Skeleton/Image */}
  		<View style={[styles.itemContainer]}>
  			{/* width and height of the skeleton must be specified */}
  			<Skeleton
  				isLoading={isLoading}
  				style={styles.skeletonAvatar}
  				baseColor={colors.Neutral50}
  				shimmerColor={colors.Neutral70}
  			>
  				<Image source={{ uri: avatarUrl }} style={styles.actualAvatar} />
  			</Skeleton>

  			{/* Text Lines Container */}
  			<View style={styles.textContainer}>
  				{/* Name Skeleton/Text */}
  				<Skeleton
  					isLoading={isLoading}
  					style={styles.skeletonLineLong}
  					reduceMotion="never"
  					baseColor={colors.Neutral50}
  					shimmerColor={colors.Neutral70}
  				>
  					<Text style={[styles.nameText, { color: colors.Neutral900 }]} numberOfLines={1}>
  						{name}
  					</Text>
  				</Skeleton>

  				{/* Description Skeleton/Text */}
  				<Skeleton
  					isLoading={isLoading}
  					style={styles.skeletonLineShort}
  					reduceMotion="never"
  					baseColor={colors.Neutral50}
  					shimmerColor={colors.Neutral70}
  				>
  					<Text style={[styles.descriptionText, { color: colors.Neutral500 }]} numberOfLines={1}>
  						{description}
  					</Text>
  				</Skeleton>
  			</View>
  		</View>
  		<Button title={"Reload Animation"} onPress={triggerReload} disabled={isLoading} color={colors.PrimaryNormal} />
  	</SafeAreaView>
  );
}

const styles = StyleSheet.create({
  screen: {
  	flex: 1,
  	paddingTop: 50,
  	alignItems: "center",
  	justifyContent: "center",
  },
  itemContainer: {
  	flexDirection: "row",
  	alignItems: "center",
  	paddingVertical: 12,
  	paddingHorizontal: 16,
  },
  skeletonAvatar: {
  	width: AVATAR_SIZE,
  	height: AVATAR_SIZE,
  	borderRadius: AVATAR_SIZE / 2,
  	marginRight: 12,
  },
  textContainer: {
  	flex: 1,
  	justifyContent: "center",
  },
  skeletonLineLong: {
  	height: 18,
  	width: "95%",
  	borderRadius: 4,
  	marginBottom: 8,
  },
  skeletonLineShort: {
  	height: 14,
  	width: "75%", // Shorter line
  	borderRadius: 4,
  },
  actualAvatar: {
  	width: AVATAR_SIZE,
  	height: AVATAR_SIZE,
  	borderRadius: AVATAR_SIZE / 2,
  },
  nameText: {
  	fontSize: 16,
  	fontWeight: "600",
  	lineHeight: 18,
  	marginBottom: 8,
  },
  descriptionText: {
  	fontSize: 12,
  	lineHeight: 14,
  },
});

Props

PropTypeDefaultRequiredDescription
isLoadingbooleanYesControls whether the skeleton placeholder or the actual children are rendered.
baseColorstringYesBackground color of the skeleton
shimmerColorstringYesColor of the moving shimmer highlight
childrenReactNodeNoThe actual content to display once isLoading becomes false.
styleStyleProp<ViewStyle>YesCustom styles applied to the skeleton placeholder View. Crucial for setting width, height, borderRadius, etc.
durationnumber1000NoThe duration (in milliseconds) for one cycle of the shimmer animation.
reduceMotion'always' | 'never' | 'system''system'NoControls if/when animations are disabled ('always', 'never', or based on device accessibility settings).