React Native Skeleton Loader with a Shimmer Effect
Updated
A React Native skeleton loader is a temporary layout-shaped placeholder that uses a clipped gradient to create a shimmer while content loads. Here you will build one synchronized shimmer with Expo SDK 57, React Native 0.86, expo-linear-gradient, and Reanimated 4.5.1, then reveal the real content without frame-by-frame React renders.
What you'll build
The component accepts any placeholder layout and any loaded content. One shared progress value moves a single diagonal highlight across the whole group, which makes separate text and image blocks feel like one material instead of several unrelated animations.
The loader also reports a busy accessibility state, becomes static when Reduce Motion is enabled, and keeps layout dimensions stable during the content swap. The result is appropriate for cards, profile headers, list rows, and detail screens.
Prerequisites
Use Expo SDK 57, React Native 0.86, Reanimated 4.5.1, react-native-worklets 0.10.1, and expo-linear-gradient. This effect does not need Gesture Handler, Skia, Blur, or Haptics.
Install versions resolved for the current Expo SDK:
npx expo install react-native-reanimated react-native-worklets expo-linear-gradientThe official Expo LinearGradient documentation covers the native gradient component used for the highlight. For animation setup and supported platforms, use the SDK 57 Reanimated page.
Design the placeholder from the real layout
A useful skeleton predicts the structure that will replace it. Match the eventual image ratio, title width, line count, and spacing; do not fill the screen with arbitrary gray bars. Stable geometry reduces perceived waiting and prevents the interface from jumping when data arrives.
The reusable block below supports rectangular, circular, and text-like shapes. The parent owns the shimmer so every block shares the same light pass.
Build the synchronized shimmer
Create ShimmerSkeleton.tsx:
import { useEffect, type ReactNode } from 'react';
import {
StyleSheet,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
Easing,
Extrapolation,
interpolate,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';
type SkeletonBlockProps = {
width?: number | `${number}%`;
height?: number;
variant?: 'rectangle' | 'circle' | 'text';
style?: StyleProp<ViewStyle>;
};
export function SkeletonBlock({
width = '100%',
height = 16,
variant = 'rectangle',
style,
}: SkeletonBlockProps) {
const radius =
variant === 'circle' || variant === 'text' ? height / 2 : 14;
return (
<View
style={[
styles.block,
{ width, height, borderRadius: radius },
style,
]}
/>
);
}
type ShimmerSkeletonProps = {
loading: boolean;
placeholder: ReactNode;
children: ReactNode;
duration?: number;
accessibilityLabel?: string;
style?: StyleProp<ViewStyle>;
};
export function ShimmerSkeleton({
loading,
placeholder,
children,
duration = 1400,
accessibilityLabel = 'Loading content',
style,
}: ShimmerSkeletonProps) {
const reducedMotion = useReducedMotion();
const progress = useSharedValue(-1);
useEffect(() => {
if (!loading) {
progress.set(
reducedMotion
? 2
: withTiming(2, {
duration: 320,
easing: Easing.out(Easing.cubic),
}),
);
return;
}
progress.set(-1);
if (reducedMotion) {
progress.set(0);
return;
}
progress.set(
withRepeat(
withTiming(1, { duration, easing: Easing.linear }),
-1,
false,
),
);
}, [duration, loading, progress, reducedMotion]);
const shimmerStyle = useAnimatedStyle(() => ({
opacity: reducedMotion ? 0.35 : 1,
transform: [
{
translateX: interpolate(progress.get(), [-1, 1], [-320, 320]),
},
{ rotate: '-12deg' },
],
}));
const placeholderStyle = useAnimatedStyle(() => ({
opacity: interpolate(
progress.get(),
[1, 2],
[1, 0],
Extrapolation.CLAMP,
),
}));
const contentStyle = useAnimatedStyle(() => ({
opacity: interpolate(
progress.get(),
[1, 2],
[0, 1],
Extrapolation.CLAMP,
),
}));
return (
<View
accessibilityLabel={loading ? accessibilityLabel : undefined}
accessibilityLiveRegion="polite"
accessibilityState={{ busy: loading }}
style={[styles.root, style]}
>
<Animated.View
pointerEvents={loading ? 'auto' : 'none'}
style={[styles.placeholder, placeholderStyle]}
>
<View
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={styles.clip}
>
{placeholder}
<Animated.View pointerEvents="none" style={[styles.shimmer, shimmerStyle]}>
<LinearGradient
colors={[
'rgba(255,255,255,0)',
'rgba(255,255,255,0.85)',
'rgba(255,255,255,0)',
]}
end={{ x: 1, y: 0.5 }}
start={{ x: 0, y: 0.5 }}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
</View>
</Animated.View>
<Animated.View
accessibilityElementsHidden={loading}
importantForAccessibility={loading ? 'no-hide-descendants' : 'auto'}
pointerEvents={loading ? 'none' : 'auto'}
style={contentStyle}
>
{children}
</Animated.View>
</View>
);
}
export function ArticleCardSkeleton() {
return (
<View style={styles.cardPlaceholder}>
<SkeletonBlock height={164} />
<SkeletonBlock height={20} width="72%" variant="text" />
<SkeletonBlock height={14} width="94%" variant="text" />
<SkeletonBlock height={14} width="61%" variant="text" />
<View style={styles.authorRow}>
<SkeletonBlock height={36} width={36} variant="circle" />
<SkeletonBlock height={14} width={110} variant="text" />
</View>
</View>
);
}
const styles = StyleSheet.create({
root: { minHeight: 280 },
placeholder: {
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0,
},
clip: { flex: 1, overflow: 'hidden' },
block: { backgroundColor: '#E7E8EE', overflow: 'hidden' },
shimmer: {
bottom: -80,
position: 'absolute',
top: -80,
width: 150,
},
cardPlaceholder: { gap: 14, padding: 20 },
authorRow: { alignItems: 'center', flexDirection: 'row', gap: 12 },
});Use it at the point where the data boundary is known:
<ShimmerSkeleton
loading={article === null}
placeholder={<ArticleCardSkeleton />}
>
{article ? <ArticleCard article={article} /> : null}
</ShimmerSkeleton>How the animation works
The shared value travels from -1 to 1, then withRepeat restarts it forever while loading. interpolate maps that normalized phase to a horizontal translation wide enough to begin and end outside the clipped container. The highlight is taller than the card and rotated, creating a diagonal sweep without animating every placeholder separately.
Linear easing is important for a repeating shimmer. An ease-in or ease-out curve visibly slows at the edges and makes the restart feel like a pulse. A cycle around 1,200–1,600 ms usually reads as active without becoming distracting.
There is exactly one continuously animated shared value. The block layout is static React Native view geometry, and the native gradient moves on the UI thread. That is cheaper and more coherent than creating one repeating animation per line.
Reveal content without layout shift
The compact example switches branches when loading changes. Because root has a stable minimum height and the placeholder mirrors the content, the layout should not collapse. For variable content, measure or reserve the expected container dimensions from the design rather than guessing after the request starts.
If you want a crossfade, keep both branches absolutely overlaid and derive their opacity from a short, nonrepeating shared transition value. Do not reuse the repeating sweep phase for reveal timing: a request can finish at any point in the cycle. In a larger abstraction, one shared “mode progress” can take over after the sweep is canceled, but clarity is more valuable than forcing unrelated phases into one number.
Do not keep skeletons visible for a fixed artificial delay. Fast data should render quickly. If flashes from cached responses are a concern, delay showing the skeleton by roughly 100–150 ms, but cancel that timer as soon as content arrives.
Accessibility
A skeleton has no useful internal semantics, so hide its decorative blocks from screen readers. The wrapper exposes accessibilityState={{ busy: loading }} and a concise label such as “Loading profile.” Once content replaces the placeholder, the real headings, images, and controls become the accessible children.
The polite live region avoids interrupting whatever the user is currently hearing. For a full-screen critical load, you may announce completion separately, but avoid repeating “loading” on every shimmer cycle.
When useReducedMotion is true, this implementation stops the repeating travel and displays a static, low-opacity highlight. The loading state remains visually distinct without continuous movement. Reduced Motion affects movement, not the amount of information presented.
Performance
Animate one overlay, not every block. Transforms and opacity are efficient animation properties, and Reanimated evaluates the style on the UI thread. There is no React update per frame and no JS timer powering the shimmer.
Keep the gradient modestly wider than the strongest highlight. A full-screen translucent overlay creates unnecessary overdraw, particularly in long lists. Render skeletons only for visible rows, give list items stable keys, and avoid nesting multiple shimmer providers around the same region.
Use a shared placeholder component for repeated cards, but tailor its dimensions to the actual card variant. Memoizing an entire placeholder tree may help very large lists, yet a few plain views are usually cheaper than the coordination overhead. Profile on a lower-end Android device before optimizing blindly.
Common pitfalls
- Animating every gray block independently. Unsynchronized highlights look noisy and multiply animation work. Sweep one gradient across the group.
- Using arbitrary placeholder geometry. The content jumps when loaded because its image, lines, or spacing do not match the skeleton.
- Leaving skeletons accessible. A screen reader may announce meaningless empty views. Hide the shapes and label the busy container.
- Running a JavaScript interval for frames. Reanimated's shared value can repeat on the UI thread without causing component rerenders.
- Forgetting clipping. Without
overflow: 'hidden', the bright strip travels outside the card and across neighboring content. - Shimmering forever after an error. Loading, success, empty, and error are separate states. Render recovery UI when the request fails.
- Making the highlight too bright. High contrast can look like flashing. Keep the base and highlight close enough to feel like reflected light.
For deeper timing behavior, see the official withRepeat API and useReducedMotion reference.
Skip the build: production-ready version
The production-ready Shimmer Skeleton adds a reusable placeholder API, configurable material colors and duration, a polished content reveal, and a synchronized diagonal sheen across arbitrary block layouts. It includes reduced-motion and busy-state behavior and is tested on iOS, Android, and web. For another loading pattern, compare the animated counter guide, where motion communicates changing data rather than missing data.
FAQ
What is a skeleton loader in React Native?
It is a temporary set of shapes that approximates the layout of content while data is loading. Unlike a generic spinner, it preserves spatial context and can reduce perceived waiting when it accurately matches the final screen.
How do I create a shimmer effect in React Native?
Place a narrow LinearGradient above the skeleton, clip it inside the container, and animate its horizontal transform with a repeating Reanimated shared value. One overlay can illuminate the entire placeholder group.
Should a skeleton loader use an ActivityIndicator too?
Usually not for the same content region. A skeleton already communicates loading; adding a spinner duplicates the message and increases visual noise. Use an activity indicator for indeterminate actions that have no meaningful destination layout.
How long should a skeleton screen remain visible?
Only as long as the data is genuinely pending. Consider a short delayed appearance to avoid a flash for very fast requests, but never hold back ready content just to finish an animation cycle.