Mehdi Davoodi12 min read
React Native skeleton loading: shape beats shimmer
A skeleton must mirror the real layout exactly or you trade a spinner for layout shift. A Reanimated shimmer that shares its styles, plus the library options.
A skeleton loader in React Native is an animated placeholder that occupies the exact box your real content will occupy, usually with a gradient sweep driven by Reanimated on the UI thread. The shimmer is one small file you write once. The part everyone gets wrong is "the exact box": if the placeholder is 60pt tall and the real row lands at 72pt, you have not replaced the spinner, you have replaced it with a layout shift.
Most skeleton tutorials spend the bulk of their words on the sweep animation
and about a sentence on sizing. That ratio is backwards. The sweep is a
translateX on a gradient. The sizing is the entire reason
skeletons feel better than spinners, and it is the thing that silently rots
the moment somebody changes a font size.
What is a skeleton loader in React Native?
It is three things stacked: a grey box with the real component's dimensions, a
moving highlight to signal "working, not frozen", and a swap to the real
content when the data arrives. Nothing about it is React Native specific
except how you animate it, and on React Native the answer is
react-native-reanimated, because the animation has to keep
running on the UI thread while the JS thread is busy parsing the response you
are waiting for. A skeleton animated with setState stutters
precisely when you need it not to.
A spinner says "something is happening somewhere". A skeleton says "a list of five rows with an avatar and two lines of text is about to appear, right here". That second message is the whole product. It only works if it is true.
Why does the skeleton have to match the layout exactly?
Because the swap is not a cross-fade, it is a re-layout. When your placeholder row is shorter than the real row, every row below it moves down at the moment the data lands. On a list of twenty items that is twenty small jumps at once, and it always happens at the exact second the user has committed their thumb to a tap. Web people have a metric for this (CLS). React Native does not report a number, which is the only reason this bug ships so often.
The skeleton's outer container must be the same container as the real component's, with the same height, padding, gap, border radius and flex direction. If you are typing those values a second time, you have already introduced the drift. Import them instead.
The sneakiest source of mismatch is text. A <Text> without
an explicit lineHeight is measured by the platform's font
metrics, and those differ between iOS and Android. You cannot guess the box
height of text you have not rendered. So set lineHeight
explicitly on the text you intend to skeleton, and use that number as the
placeholder's height.
That fixes iOS versus Android. It does not fix font scale, and font scale is
the one that bites in production. React Native multiplies
fontSize and lineHeight by the OS setting, but a
<View> with height: 20 is 20 points at every
setting. At 1.5x Dynamic Type your real name line is 30 points tall while the
placeholder is still 20, and if the row is pinned to height: 72
the real text gets clipped on top of that. So do two things: give the
container a minHeight rather than a height, and
multiply the placeholder heights by the live font scale, which
useWindowDimensions() hands you and updates when the setting
changes. If a design genuinely cannot flex,
allowFontScaling={false} on that text makes the placeholder
honest again, at a real accessibility cost you should be choosing on purpose
rather than inheriting by accident.
How do you build a shimmer skeleton with Reanimated?
One shared value from 0 to 1 on a loop, one useAnimatedStyle that
turns it into a translateX, one linear gradient masked by
overflow: 'hidden'. This targets Reanimated 4, so Expo SDK 54 or
newer, and needs expo-linear-gradient. Note what the component
does not take: width and height props. It takes a style, because the
style is the thing you are going to share with the real component.
import { useEffect, useState } from 'react';
import {
StyleSheet,
View,
type LayoutChangeEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
Easing,
cancelAnimation,
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';
const AnimatedGradient = Animated.createAnimatedComponent(LinearGradient);
type SkeletonProps = {
/**
* The same style object the real element uses. Width, height and
* border radius all arrive from here, so nothing gets typed twice.
*/
style?: StyleProp<ViewStyle>;
};
export function Skeleton({ style }: SkeletonProps) {
const progress = useSharedValue(0);
const [boxWidth, setBoxWidth] = useState(0);
const reduceMotion = useReducedMotion();
useEffect(() => {
if (reduceMotion) return;
progress.value = withRepeat(
withTiming(1, { duration: 1100, easing: Easing.linear }),
-1,
false,
);
// an infinite repeat outlives the row unless you stop it
return () => cancelAnimation(progress);
}, [progress, reduceMotion]);
// start one full box to the left, finish one full box to the right
const sweep = useAnimatedStyle(
() => ({
transform: [
{ translateX: -boxWidth + progress.value * boxWidth * 2 },
],
}),
[boxWidth],
);
const onLayout = (event: LayoutChangeEvent) => {
setBoxWidth(event.nativeEvent.layout.width);
};
return (
<View
onLayout={onLayout}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
style={[styles.box, style]}
>
{!reduceMotion && boxWidth > 0 && (
<AnimatedGradient
colors={['transparent', 'rgba(255,255,255,0.65)', 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.sweep, { width: boxWidth }, sweep]}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
box: { backgroundColor: '#E7E7EC', overflow: 'hidden' },
sweep: { position: 'absolute', top: 0, bottom: 0, left: 0 },
});
Three notes on that. withRepeat(..., -1, false) means "forever,
no reverse", so the highlight always travels the same direction. It snaps back
at the loop boundary and you never see the snap, because at that instant the
gradient is parked a full box width outside the clip: it spans
[width, 2 * width] and the box spans [0, width], so
there is nothing on screen to jump. Transparent ends soften the sweep, but
they are not what hides the seam.
Second, the width comes from onLayout rather than a prop. That is
the point of the whole file: the box is sized by the style you share with the
real component, and the sweep measures whatever that turns out to be. Third,
cancelAnimation in the cleanup is not optional on a list. An
infinite withRepeat started in an effect keeps ticking after the
row unmounts unless you stop it.
Reanimated 4 requires the New Architecture. On the old one you are on
Reanimated 3.x, where this file works unchanged from 3.5.0 up, which is where
useReducedMotion landed. Below 3.5 the import fails: drop the
hook and read the setting from AccessibilityInfo instead.
If you do not want the expo-linear-gradient dependency, the
honest alternative is an opacity pulse: animate one shared value between 0.45
and 1 with withRepeat(withTiming(...), -1, true) and apply it as
opacity. It is one animated style instead of a gradient plus a
clip, it costs less on a long list, and most users cannot tell you which one
an app used ten seconds later. Shimmer looks more expensive. Pulse is more
robust. Pick per screen, not per codebase.
How do you keep the skeleton and the real component in sync?
Share the style object. Not the values, the object. The real row and its
skeleton import the same StyleSheet, so a padding change lands in
both or in neither, and the placeholder boxes live in that same file so they
cannot drift from the text they stand in for.
// contact-row.styles.ts: the single source of truth for the box
import { StyleSheet } from 'react-native';
export const NAME_LINE = 20;
export const HANDLE_LINE = 16;
export const row = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
// minHeight, not height, so the row can grow with Dynamic Type
minHeight: 72,
paddingHorizontal: 16,
},
avatar: { width: 44, height: 44, borderRadius: 22 },
body: { flex: 1, gap: 6 },
name: { fontSize: 15, lineHeight: NAME_LINE },
handle: { fontSize: 13, lineHeight: HANDLE_LINE, color: '#8A8A93' },
// the placeholders live here too; height is applied at render,
// scaled by the same font scale the text above is scaled by
namePlaceholder: { width: 140, borderRadius: 6 },
handlePlaceholder: { width: 90, borderRadius: 6 },
});
// ContactRow.tsx
import { Image, Text, View } from 'react-native';
import { row } from './contact-row.styles';
export type Contact = {
id: string;
name: string;
handle: string;
avatar: string;
};
export function ContactRow({ contact }: { contact: Contact }) {
return (
<View style={row.container}>
<Image source={{ uri: contact.avatar }} style={row.avatar} />
<View style={row.body}>
<Text style={row.name}>{contact.name}</Text>
<Text style={row.handle}>{contact.handle}</Text>
</View>
</View>
);
}
// ContactRow.skeleton.tsx: same container, same style objects,
// zero numbers typed a second time.
import { useWindowDimensions, View } from 'react-native';
import { Skeleton } from './Skeleton';
import { HANDLE_LINE, NAME_LINE, row } from './contact-row.styles';
export function ContactRowSkeleton() {
const { fontScale } = useWindowDimensions();
return (
<View style={row.container}>
<Skeleton style={row.avatar} />
<View style={row.body}>
<Skeleton
style={[row.namePlaceholder, { height: NAME_LINE * fontScale }]}
/>
<Skeleton
style={[row.handlePlaceholder, { height: HANDLE_LINE * fontScale }]}
/>
</View>
</View>
);
}
// One label for the whole list, not one per box.
export function ContactListSkeleton({ count = 8 }: { count?: number }) {
return (
<View
accessible
accessibilityRole="progressbar"
accessibilityLabel="Loading contacts"
>
{Array.from({ length: count }, (_, i) => (
<ContactRowSkeleton key={i} />
))}
</View>
);
}
The avatar placeholder is 44 wide because it is
row.avatar, the same object the <Image> uses.
The two text placeholders are NAME_LINE and
HANDLE_LINE tall because those are the exact constants the
<Text> styles set as their lineHeight, and
both get multiplied by the same font scale the OS is applying to the text.
Change the name to 17pt and the placeholder follows, because there is no
second copy of the number to forget.
Then render a small fixed number of them, which is what the
count = 8 default is doing. Nobody scrolls a skeleton, so eight
rows is plenty even if the response returns two hundred. Eight looping
animations is a rounding error; two hundred is a decision you will regret on a
mid-range Android.
Should you use a skeleton library instead?
Sometimes. The libraries differ less in how they shimmer and more in how they decide the shape, which is the part that actually matters.
| Approach | How the shape is decided | Cost | Best when |
|---|---|---|---|
| Hand-rolled (above) | You write it, sharing the real styles | Reanimated + gradient you already have | You own the design system and want zero new deps |
moti/skeleton |
Wraps the real content, per-box props, grouped by a parent | moti + Reanimated + expo-linear-gradient | You already use moti; nicest ergonomics of the bunch |
react-native-reanimated-skeleton |
A layout array of box descriptors |
Reanimated + react-native-linear-gradient (not the Expo one) |
You want the placeholder declared as data, not JSX |
react-native-auto-skeleton |
Derived natively from your rendered view tree | A native module, New Architecture | You have many screens and no appetite to maintain twins |
react-content-loader |
SVG shapes you draw | react-native-svg, imported from react-content-loader/native |
Odd non-rectangular placeholders |
Two of those costs are worth reading twice.
react-native-reanimated-skeleton lists
react-native-linear-gradient in its peer dependencies, so on a
managed Expo project it is a second gradient library rather than the one you
already have. And react-content-loader only works in React Native
through its /native entry point; the bare package name resolves
to the DOM build and fails at runtime.
moti's version is the one we reach for when moti is already in the project.
The important detail is that its Skeleton wraps the real content
rather than replacing it: when show flips to false, the
placeholder fades out and the children underneath are what remains, so the
box never changes size.
import { Image, Text, View } from 'react-native';
import { Skeleton } from 'moti/skeleton';
import { NAME_LINE, row } from './contact-row.styles';
import type { Contact } from './ContactRow';
export function MotiContactRow({
contact,
isLoading,
}: {
contact?: Contact;
isLoading: boolean;
}) {
return (
<View style={row.container}>
<Skeleton.Group show={isLoading}>
<Skeleton colorMode="light" radius="round" width={44} height={44}>
{contact ? (
<Image source={{ uri: contact.avatar }} style={row.avatar} />
) : null}
</Skeleton>
<View style={row.body}>
<Skeleton colorMode="light" width={140} height={NAME_LINE}>
{contact ? (
<Text style={row.name}>{contact.name}</Text>
) : null}
</Skeleton>
</View>
</Skeleton.Group>
</View>
);
}
Check its peer range against your Reanimated version before you install it; the skeleton package is a thin layer over moti, which is a thin layer over Reanimated, and version skew there surfaces as a build error rather than a warning.
The auto-generating option is the genuinely different idea in this list. Instead of you maintaining a second component that mimics the first, it reads the views you already rendered and paints placeholders over them, which makes the layout match by construction rather than by discipline. The trade is a native dependency and less control over exactly which nodes get covered. If your app has forty screens that each need a skeleton, that trade is probably worth taking. If it has three, it is not.
Whichever you pick, the skeleton is a component that ships with the real component, not a thing you bolt on afterwards. Our shimmer input drop is the same sweep applied to a single field rather than a list, which is the other place this pattern earns its keep: a field sitting on a round trip whose latency it cannot predict.
When should you not show a skeleton?
When the wait is short enough that the skeleton itself becomes the flicker. A placeholder that appears and vanishes inside 200ms reads as a glitch, not as feedback. A delay alone does not fix that, which is where most versions of this hook stop: gate the skeleton behind 150ms and a 300ms request still flashes it for 150ms, right inside the glitch window. You need both halves, a delay before it appears and a floor on how long it stays.
import { useEffect, useRef, useState } from 'react';
/**
* True once `loading` has been true for `delay` ms, and then true for
* at least `minVisible` ms, so the skeleton can never flash.
*/
export function useSlowLoad(
loading: boolean,
delay = 150,
minVisible = 400,
) {
const [visible, setVisible] = useState(false);
const shownAt = useRef<number | null>(null);
useEffect(() => {
if (loading) {
const id = setTimeout(() => {
shownAt.current = Date.now();
setVisible(true);
}, delay);
return () => clearTimeout(id);
}
// never got shown: nothing to hold on screen
if (shownAt.current === null) {
setVisible(false);
return;
}
const elapsed = Date.now() - shownAt.current;
const id = setTimeout(
() => {
shownAt.current = null;
setVisible(false);
},
Math.max(0, minVisible - elapsed),
);
return () => clearTimeout(id);
}, [loading, delay, minVisible]);
return visible;
}
So a 90ms response renders straight to content and never shows a placeholder, and a 300ms response shows one for the full 400ms. The cost is that the fast case is sometimes made slightly slower on purpose. That is the correct trade: a steady 550ms reads as fast, and a 150ms blink reads as broken.
Three more places to skip it:
- Pull to refresh. The content is already on screen. Use the
RefreshControlspinner and leave the list alone. Replacing visible content with placeholders is a downgrade. - Pagination. A footer spinner is correct. The user is not waiting to find out what the screen looks like, they already know.
- Unknown shape. If the response decides the layout (a feed of mixed card types, a search result that might be empty), a skeleton is a guess, and a wrong guess costs you the layout shift you were trying to avoid. Use a neutral loader, or skeleton only the parts whose shape is fixed.
What about reduced motion and screen readers?
Reanimated ships useReducedMotion(), and it is worth knowing
exactly what it returns: the value of the OS setting as it was when the app
started. It is a module-level constant, so flipping the setting mid-session
does not re-render anything. For a skeleton that is usually fine, and it is
what the snippet above uses to skip the sweep and render a static grey box,
which is still a perfectly good skeleton: the shape does the communicating,
the motion is decoration. If you want it to react while the app is open, React
Native's AccessibilityInfo.isReduceMotionEnabled() plus its
reduceMotionChanged listener is the live version, and it is a
strictly better signal for three more lines of code.
For screen readers, the individual boxes are noise. Note where the two
accessibility props sit in the code above: every Skeleton hides
itself and its descendants (accessibilityElementsHidden on iOS,
importantForAccessibility="no-hide-descendants" on Android), and
the single label lives on ContactListSkeleton. That is one
"Loading contacts" announcement instead of two dozen unlabelled views, and
instead of the other failure mode, which is putting the label on the box and
making VoiceOver say "Loading, progress bar" once per placeholder.
Other components written up the same way, free and copy-paste: stopwatch and number input.
Is a skeleton better than a spinner?
Only when it tells the truth about the layout. A spinner communicates that something is happening; a skeleton communicates what is about to appear and where, which is more useful and measurably calmer to wait through. But a skeleton whose boxes are the wrong size is worse than a spinner, because the swap to real content shoves everything below it down the screen at the exact moment the user reaches for a tap target. A spinner has no layout to get wrong. If you cannot predict the shape of the response, the spinner is the correct choice.
How do I make a shimmer effect in React Native?
Put a linear gradient that goes transparent, light, transparent inside a view
with overflow: 'hidden', then animate the gradient's
translateX from minus the view width to plus the view width on an
infinite loop. In Reanimated that is one useSharedValue driven by
withRepeat(withTiming(...), -1, false) and one
useAnimatedStyle returning the transform, with
cancelAnimation in the effect cleanup so the loop stops when the
row unmounts. On Expo the gradient comes from
expo-linear-gradient, wrapped with
Animated.createAnimatedComponent so it can take an animated
style.
Which React Native skeleton library should I use?
If moti is already in your project, moti/skeleton has the
cleanest API: it wraps your real content and a group wrapper toggles all
children together. If you want the placeholder declared as data instead of
JSX, react-native-reanimated-skeleton takes an array of box
descriptors, though note it peer-depends on
react-native-linear-gradient rather than the Expo gradient. If
you have many screens and do not want to maintain a skeleton twin for each
one, react-native-auto-skeleton derives the shapes natively from
your already-rendered view tree. If you only need a handful of placeholders,
hand-rolling it is a single small file and adds no dependency beyond a
gradient.
Do skeleton screens cause layout shift?
Only when the placeholder and the real content disagree about size, which is
the common case rather than the rare one. The fix is to share the same
StyleSheet between the real component and its skeleton, to set an
explicit lineHeight on any text you are placeholdering so the box
height is a number you control rather than a platform font metric, and to
multiply that number by useWindowDimensions().fontScale so it
survives Dynamic Type. If the two agree, the swap is invisible and nothing
below the row moves.
Should a skeleton respect reduced motion?
Yes. When the setting is on, render the static grey boxes without the sweep:
the layout is what does the communicating, so a skeleton with no animation
still works. Reanimated's useReducedMotion() is the one-line
option, but it reads the setting once at app start and will not re-render if
the user changes it while your app is open. React Native's
AccessibilityInfo.isReduceMotionEnabled() plus the
reduceMotionChanged listener is the live version. Pair either
with hiding the individual boxes from screen readers and putting a single
"Loading" label on the container that holds them.
The rule worth keeping
Write the skeleton in the same file tree as the component it stands in for, importing the same styles, and delete both together. The moment it becomes a separate artefact maintained by a separate person, it starts lying about the layout, and a skeleton that lies about the layout is worse than the spinner it replaced. Everything else here, the sweep direction, shimmer versus pulse, the library table, is preference. The shared box is not.
React NativeExpoReanimatedSkeletonLoading statesAccessibility