The four options
| Approach | Needs | Expo Go | Best for | Cost |
|---|---|---|---|---|
| Hand-rolled + Reanimated | react-native-reanimated | ✅ Yes | Matching your own design tokens exactly | You write ~30 lines |
| moti/skeleton | moti + reanimated + linear-gradient | ✅ Yes | A gradient shimmer with no animation code | Three dependencies for one visual |
| react-content-loader | react-native-svg | ✅ Yes | Irregular shapes drawn as SVG | Placeholders live outside your layout system |
| No skeleton at all | — | ✅ Yes | Cached data that resolves in under ~150 ms | Nothing — this is often correct |
The last row is not a joke. Skeletons exist to cover a wait, and if there is no wait — a list read from local storage, a screen you prefetched on the previous tab — the correct placeholder is nothing at all. Adding one there converts an instant screen into a flickering one.
One block, done properly
Everything else composes out of this. A rounded view, a looping opacity animation, and a style prop so callers control size:
import { useEffect } from 'react';
import Animated, {
useAnimatedStyle, useSharedValue,
withRepeat, withSequence, withTiming, Easing,
} from 'react-native-reanimated';
export function Skeleton({ width, height, radius = 8, style }) {
const pulse = useSharedValue(0.5);
useEffect(() => {
pulse.value = withRepeat(
withSequence(
withTiming(1, { duration: 700, easing: Easing.inOut(Easing.quad) }),
withTiming(0.5, { duration: 700, easing: Easing.inOut(Easing.quad) }),
),
-1, // forever
false,
);
}, [pulse]);
// opacity only — never animate width/height/backgroundColor per frame
const animated = useAnimatedStyle(() => ({ opacity: pulse.value }));
return (
<Animated.View
accessibilityElementsHidden // screen readers skip placeholders
importantForAccessibility="no-hide-descendants"
style={[
{ width, height, borderRadius: radius, backgroundColor: '#2a2a2a' },
animated,
style,
]}
/>
);
}The two accessibility props matter more than they look. Without them, VoiceOver and TalkBack happily announce a screen full of empty decorative boxes, which is a genuinely bad experience for someone who cannot see that they are placeholders.
Note what is not animated: only opacity changes. Animating width or backgroundColor per frame forces layout or colour interpolation on every tick — see the Reanimated guide for why transform and opacity are the only free properties.
Mirror the real row, exactly
The whole value of a skeleton is that nothing moves when data lands. That only holds if the placeholder row uses the same container, the same padding, and the same avatar size as the loaded row:
function PostRowSkeleton() {
return (
<View style={styles.row}> {/* same styles.row as the real row */}
<Skeleton width={44} height={44} radius={22} />
<View style={styles.rowBody}>
<Skeleton width="70%" height={14} />
<Skeleton width="45%" height={12} style={{ marginTop: 8 }} />
</View>
</View>
);
}
export function PostList({ posts, isLoading }) {
if (isLoading) {
return (
<View>
{Array.from({ length: 6 }).map((_, i) => <PostRowSkeleton key={i} />)}
</View>
);
}
return <FlatList data={posts} renderItem={renderPost} keyExtractor={(p) => p.id} />;
}Six rows, not sixty. The skeleton covers the first screen; anything below the fold is invisible work. And keep the placeholder text widths uneven — 70% then 45% — because a stack of identical full-width bars reads as a loading bar, not as text that is about to arrive.
The timing rules nobody implements
This is the difference between a skeleton that helps and one that makes your app feel unstable. Two thresholds, both cheap:
import { useEffect, useRef, useState } from 'react';
// show nothing for fast loads; once shown, stay long enough to be read
export function useSkeletonVisible(isLoading, { delay = 150, minVisible = 300 } = {}) {
const [visible, setVisible] = useState(false);
const shownAt = useRef(0);
useEffect(() => {
let timer;
if (isLoading) {
timer = setTimeout(() => { shownAt.current = Date.now(); setVisible(true); }, delay);
} else if (visible) {
const remaining = minVisible - (Date.now() - shownAt.current);
timer = setTimeout(() => setVisible(false), Math.max(0, remaining));
}
return () => clearTimeout(timer);
}, [isLoading, visible, delay, minVisible]);
return visible;
}With that in place a 40 ms cached read shows no placeholder at all, a 900 ms network read shows a stable skeleton, and a 200 ms read does not flicker. The numbers are not sacred — 150 ms and 300 ms are reasonable starting points that roughly match human perception of “instant” and “long enough to register”. Tune them against your own p50 and p95 response times.
One more rule worth stating plainly: a skeleton is not an error state. If the request fails, replace it with a message and a retry — do not leave a shimmer running forever. Shimmering placeholders on a screen whose request died three minutes ago is the most common version of this bug in shipped apps.
Where skeletons fit in a real app
Feeds, profiles, detail screens, dashboards — anywhere the layout is known before the data is. Not for submits, not for searches with unknown result counts, and not for screens you can make instant instead. A list backed by local storage that syncs in the background needs no skeleton at all, which is one of the arguments in the offline-first guide.
If you would rather not hand-write loading states for every screen, describe the app instead — “a feed of posts with avatars, a detail screen, and pull to refresh” — and ShipNative generates the screens as real React Native with loading and empty states included, running on your phone so you can see whether the skeleton actually matches the row it replaces.