The skeleton loader is the first contact between your app and your user. That moment deserves care, not an afterthought maintained in a separate file that drifts from reality the moment you touch the real component.
react-zero-skeleton changes the relationship. The component IS its skeleton. Wrap it once. The library measures the real layout at runtime, generates one bone per element, and keeps everything in sync automatically. Forever.
// Before: two things to write, two things to maintain, two things to break.
function ArticleCard({ article }) { /* real component */ }
const ArticleCardSkeleton = () => ( /* a copy you'll forget to update */ )
// After: one thing.
const ArticleCard = withSkeleton(function ArticleCard({ article }) {
return (
<View>
<Image source={{ uri: article.cover }} style={{ height: 160 }} />
<Text style={s.title}>{article.title}</Text>
<Text style={s.excerpt}>{article.excerpt}</Text>
</View>
)
})
<ArticleCard hasSkeleton isLoading={isLoading} article={data} />npm install react-zero-skeletonNo native code. No pod install. No linking.
The bundler resolves the right build automatically:
- React Native / Metro → native build (Fiber walk ·
onLayout·Animated) - React web / Next.js / Vite → web build (DOM ·
ResizeObserver· CSS animations)
On the first render, the component renders invisibly. react-zero-skeleton walks the React Fiber tree (on native) or the DOM (on web), measures every View / Image / Text, and captures position, size, and corner radius. The overlay - one bone per element, positioned exactly - replaces the hidden content until isLoading becomes false.
Layout changes propagate automatically. If you add a field to the card, the skeleton gains a bone. No manual sync required.
import { withSkeleton } from 'react-zero-skeleton'
function ProfileCard({ user }) {
return (
<View style={s.wrap}>
<Image source={{ uri: user.avatar }} style={s.avatar} />
<Text style={s.name}>{user.name}</Text>
<Text style={s.bio}>{user.bio}</Text>
</View>
)
}
export default withSkeleton(ProfileCard)// Two props. That's it.
<ProfileCard hasSkeleton isLoading={isLoading} user={data} />
// Shorthand: activates hasSkeleton + isLoading at once
<ProfileCard isLoadingSkeleton user={data} />import { SkeletonTheme } from 'react-zero-skeleton'
<SkeletonTheme animation="wave" color="#E0E0E0" highlightColor="#F5F5F5">
<App />
</SkeletonTheme>Config priority: skeletonConfig prop > SkeletonTheme > defaults.
Nine animations. Each has a reason to exist.
| Animation | Character |
|---|---|
pulse |
Soft opacity fade. The default. Works everywhere, no dependencies. |
wave |
Shimmer left to right. Classic and readable. |
shiver |
Wider, more energetic wave. Good for large image placeholders. |
drip |
Vertical shimmer sweeping top to bottom, each bone phase-shifted. |
slide |
Bones drift upward while fading in. A gentle breathing effect. |
beat |
Double heartbeat: scale + opacity. Ideal for health and real-time data UIs. |
shaker |
A rapid horizontal tremor burst, then silence. For alert-style states. |
shatter |
Each bone fragments into a grid of squares. The signature animation. |
none |
Static placeholder. Useful with prefers-reduced-motion. |
// Global
<SkeletonTheme animation="shatter">…</SkeletonTheme>
// Per component
<ProfileCard hasSkeleton isLoading={isLoading} skeletonConfig={{ animation: 'beat' }} />
// Speed
skeletonConfig={{ animation: 'wave', speed: 'slow' }} // 0.5×
skeletonConfig={{ animation: 'wave', speed: 'rapid' }} // 2×
skeletonConfig={{ animation: 'wave', speed: 1.5 }} // custom multiplier
waveandshiverrequire a LinearGradient peer on React Native:npx expo install expo-linear-gradientornpm install react-native-linear-gradient. On web, CSS gradients are used: no peer needed.
When cascade > 0, each bone's animation starts delayed by bone.y × cascade ms, creating a top-to-bottom sequential wave through the component.
<SkeletonTheme animation="pulse" cascade={3}>
{/* bone at y=0 starts immediately, bone at y=100 starts 300ms later */}
</SkeletonTheme><ProfileCard
hasSkeleton
isLoading={isLoading}
skeletonConfig={{
animation: 'shatter',
shatterConfig: {
cellSize: 24, // px - overrides column count
stagger: 80, // ms delay between squares
fadeStyle: 'radial' // 'random' | 'cascade' | 'radial'
}
}}
/>The transition into and out of the skeleton is part of the experience. react-zero-skeleton gives you control over both.
Played when the skeleton first appears.
<SkeletonTheme enter="fadeUp">…</SkeletonTheme>
// 'fade' | 'fadeUp' | 'fadeDown' | 'fadeLeft' | 'fadeRight' | 'none'Played when the skeleton disappears and content takes over.
<SkeletonTheme animation="shatter" exit="fadeUp" revealOnExit>…</SkeletonTheme>revealOnExit keeps the real content visible underneath while the skeleton fades out, creating a reveal effect instead of an abrupt swap.
By default, container divs are skipped: only their text and image children receive bones. When a container is itself a visually meaningful shape (a stat card, a chip, a badge), wrap it with SkeletonBox to emit the box as a semi-transparent bone with its children rendered on top.
import { SkeletonBox } from 'react-zero-skeleton'
function Stat({ label, value }) {
return (
<SkeletonBox style={{ flex: 1, backgroundColor: '#eee', borderRadius: 10, padding: 12 }}>
<Text style={{ width: 'fit-content' }}>{label}</Text>
<Text style={{ fontWeight: '700', width: 'fit-content' }}>{value}</Text>
</SkeletonBox>
)
}The box bone is always static (no animation). Children animate normally.
Wraps elements that should never receive a skeleton bone and always remain visible during loading: section headers, timestamps, decorative labels.
import { SkeletonIgnore } from 'react-zero-skeleton'
function PriceCard({ data }) {
return (
<View style={s.wrap}>
<SkeletonIgnore>
<Text style={s.label}>Live price</Text>
</SkeletonIgnore>
<Text style={s.value}>{data.price}</Text>
<Text style={s.change}>{data.change}</Text>
</View>
)
}The measurement layer skips SkeletonIgnore entirely. The text stays visible while the dynamic content animates around it.
A block of text measures as one tall rectangle, so its skeleton is a single solid pavé. Wrap it with SkeletonParagraph and it skeletons into several lines instead: body lines get a naturally ragged width and the last line is shortened, like real text.
import { SkeletonParagraph } from 'react-zero-skeleton'
function Article({ article }) {
return (
<View>
<Text style={s.title}>{article.title}</Text>
<SkeletonParagraph size="md">
<Text style={s.body}>{article.body}</Text>
</SkeletonParagraph>
</View>
)
}size is a preset; lines overrides it with an exact count.
<SkeletonParagraph size="sm">…</SkeletonParagraph> {/* 2 lines */}
<SkeletonParagraph size="md">…</SkeletonParagraph> {/* 3 lines (default) */}
<SkeletonParagraph size="lg">…</SkeletonParagraph> {/* 5 lines */}
<SkeletonParagraph lines={7}>…</SkeletonParagraph> {/* exactly 7 */}The shortened last line follows the text alignment. Pass align explicitly, or leave it out and it is inherited from the wrapped component's textAlign (computed style on web, the wrapper's style on React Native).
{/* explicit */}
<SkeletonParagraph size="md" align="center">…</SkeletonParagraph>
{/* inherited: lines follow text-align: right */}
<div style={{ textAlign: 'right' }}>
<SkeletonParagraph size="md"><p>{body}</p></SkeletonParagraph>
</div>On React Native,
textAlignlives onText, notView. To inherit alignment, settextAlignon theSkeletonParagraphstyle, or pass thealignprop (recommended for cross-platform).
mode="words" breaks each line into word-sized bones separated by gaps, for a more text-like placeholder.
<SkeletonParagraph size="md" mode="words">
<Text>{article.body}</Text>
</SkeletonParagraph>| Prop | Type | Default | Description |
|---|---|---|---|
size |
'sm' | 'md' | 'lg' |
'md' |
Line count preset (2 / 3 / 5) |
lines |
number |
— | Exact line count; overrides size |
align |
'left' | 'center' | 'right' |
inherited → 'left' |
Alignment of the lines |
mode |
'lines' | 'words' |
'lines' |
One bar per line, or word-sized bones |
style |
ViewStyle / CSSProperties |
— | Style for the wrapper |
All widths are deterministic, so they never flicker between renders. Works on web and React Native (iOS / Android).
Skelter detects nothing itself. You bring your own signal source (NetInfo, navigator.connection, expo-battery, Low Power Mode…) and feed the values. Skelter only maps them to an animation.
Pass conditions (your live signals) and an adaptive policy to SkeletonTheme or skeletonConfig. If a rule matches, its animation is used; if nothing matches, the animation prop you already set is used unchanged — the "everything is fine" case is free.
// React Native example (NetInfo + expo-battery)
import { useNetInfo } from '@react-native-community/netinfo';
import { useBatteryLevel, useLowPowerMode } from 'expo-battery';
function App() {
const { type } = useNetInfo();
const battery = useBatteryLevel(); // 0..1
const lowPower = useLowPowerMode();
return (
<SkeletonTheme
animation="shatter" // default: used when no rule matches
conditions={{ network: type, battery, saveData: lowPower }}
adaptive={[
{ when: { network: ['slow-2g', '2g'] }, use: 'none' },
{ when: { saveData: true }, use: 'pulse' },
{ when: { batteryBelow: 0.2 }, use: 'pulse' },
{ when: { network: '3g' }, use: 'wave' },
]}
>
<HomeScreen />
</SkeletonTheme>
);
}// Web example (navigator.connection)
const conn = (navigator as any).connection;
<SkeletonTheme
animation="wave"
conditions={{
network: conn?.effectiveType,
saveData: conn?.saveData,
}}
adaptive={[
{ when: { network: ['slow-2g', '2g'] }, use: 'none' },
{ when: { saveData: true }, use: 'pulse' },
]}
>Rules are evaluated in order — the first match wins. Inside a rule, every key must hold (AND). Across rules, the first match is used (OR).
adaptive={[
// AND: all conditions in a rule must hold
{ when: { network: '3g', saveData: true }, use: 'none' },
// array = membership check
{ when: { network: ['slow-2g', '2g'] }, use: 'none' },
// numeric threshold on battery (only matches when battery is known)
{ when: { batteryBelow: 0.2 }, use: 'pulse' },
// custom signal — any key you put in conditions
{ when: { thermal: ['serious', 'critical'] }, use: 'none' },
]}Return undefined to fall through to the base animation.
adaptive={(conditions) => {
if (conditions.battery != null && conditions.battery < 0.15) return 'none';
if (conditions.network === '3g' && conditions.saveData) return 'pulse';
return undefined; // fall through → animation prop
}}Like any SkeletonConfig, the per-element skeletonConfig takes priority over SkeletonTheme. Conditions deep-merge; the adaptive policy uses the most specific level.
// Global: wave on 3g
<SkeletonTheme animation="wave" conditions={...} adaptive={...}>
// This card keeps its own policy regardless of the theme
<HeavyCard
hasSkeleton isLoading={loading}
skeletonConfig={{
animation: 'shatter',
adaptive: [{ when: { batteryBelow: 0.3 }, use: 'pulse' }],
}}
/>
</SkeletonTheme>| Field | Type | Description |
|---|---|---|
network |
NetworkType |
'offline' | 'slow-2g' | '2g' | '3g' | '4g' | '5g' | 'wifi' | 'unknown' |
battery |
number |
Battery level 0..1 |
charging |
boolean |
Whether the device is charging |
saveData |
boolean |
OS data-saver / reduced-data preference |
deviceTier |
'low' | 'mid' | 'high' |
Coarse device capability |
reducedMotion |
boolean |
Accessibility reduce-motion (handled automatically if omitted) |
[custom] |
unknown |
Any extra signal — matched by rule keys |
| Prop | Type | Description |
|---|---|---|
hasSkeleton |
boolean |
Activates skeleton mode on this instance |
isLoading |
boolean |
Shows the skeleton when true |
isLoadingSkeleton |
boolean |
Shorthand: activates hasSkeleton + isLoading |
skeletonConfig |
SkeletonConfig |
Per-instance config override |
| Prop | Type | Default | Description |
|---|---|---|---|
animation |
'pulse' | 'wave' | 'shiver' | 'drip' | 'slide' | 'beat' | 'shaker' | 'shatter' | 'none' |
'pulse' |
Animation style |
color |
string |
'#E0E0E0' |
Base bone color |
highlightColor |
string |
'#F5F5F5' |
Highlight for wave / shiver / drip |
speed |
'slow' | 'normal' | 'rapid' | number |
'normal' |
Animation speed multiplier |
borderRadius |
number |
4 |
Fallback corner radius |
direction |
'ltr' | 'rtl' |
'ltr' |
Shimmer direction |
cascade |
number |
0 |
Stagger ms per pixel of vertical position |
enter |
'fade' | 'fadeUp' | 'fadeDown' | 'fadeLeft' | 'fadeRight' | 'none' |
'none' |
Skeleton enter animation |
exit |
'fade' | 'fadeUp' | 'fadeDown' | 'fadeLeft' | 'fadeRight' | 'none' |
'fade' |
Skeleton exit animation |
revealOnExit |
boolean |
false |
Show real content under skeleton during exit |
minDuration |
number |
0 |
Minimum ms the skeleton stays visible |
disabled |
boolean |
false |
Disables skeleton entirely |
shatterConfig |
ShatterConfig |
- | Shatter animation config |
maxBonesInList |
number |
0 |
Max bones in FlatList (0 = unlimited) |
| Option | Type | Default | Description |
|---|---|---|---|
measureStrategy |
'auto' | 'root-only' |
'auto' |
auto = one bone per element; root-only = single block |
maxDepth |
number |
8 |
Max Fiber tree depth |
exclude |
string[] |
[] |
Component displayNames to skip |
mockProps |
object |
{} |
Props for the invisible warmup render |
When real props carry no data on first load, mockProps provides a realistic layout for the warmup render:
withSkeleton(ArticleCard, {
mockProps: { article: { title: 'Lorem ipsum', image: null } }
})| Prop | Type | Default | Description |
|---|---|---|---|
cellSize |
number |
0 (auto) |
Fixed cell size in px - overrides column count |
stagger |
number |
80 |
ms delay between squares |
fadeStyle |
'random' | 'cascade' | 'radial' |
'random' |
Square fade order |
Registers custom image components as leaf elements in the Fiber walk:
import { registerSkeletonLeaf } from 'react-zero-skeleton'
registerSkeletonLeaf('FastImage', 'ExpoImage')Injects hasSkeleton on all children via cloneElement. Then anywhere in the tree, only isLoading is needed:
<SkeletonTheme auto animation="wave" exclude={['MapView']}>
<App />
</SkeletonTheme>
// Anywhere in the tree:
<ArticleCard isLoading={isLoading} />const items = isLoading ? Array(6).fill(null) : realData
<FlatList
data={items}
renderItem={({ item }) => (
<ArticleCard
article={item}
hasSkeleton
isLoading={item === null}
/>
)}
/>shatter falls back to pulse automatically inside FlatList / FlashList for performance. maxBonesInList caps the number of bones per cell when lists are long.
Block-level elements expand to fill their container by default, so their bone fills the full width even if the text is short. Add width: 'fit-content' (web) or alignSelf: 'flex-start' (React Native) to make each bone match the actual content width.
// Web
<p style={{ fontSize: 16, width: 'fit-content' }}>{title}</p>
// React Native
<Text style={{ fontSize: 16, alignSelf: 'flex-start' }}>{title}</Text>| Feature | react-zero-skeleton | react-native-auto-skeleton | react-content-loader | react-loading-skeleton |
|---|---|---|---|---|
| Zero config | ✅ | ✅ | ❌ | ❌ |
| Auto-generated from live layout | ✅ | ✅ | ❌ | ❌ |
| React web support | ✅ | ❌ | ✅ | ✅ |
| Enter / exit transitions | ✅ | ❌ | ❌ | ❌ |
| Cascade stagger | ✅ | ❌ | ❌ | ❌ |
| Shatter animation | ✅ | ❌ | ❌ | ❌ |
| SkeletonBox / SkeletonIgnore | ✅ | ❌ | ❌ | ❌ |
| No native code | ✅ | ❌ | ✅ | ✅ |
| RTL support | ✅ | ❌ | ❌ | ✅ |
Fiber walk reads React internals: per-element measurement accesses _reactInternals / _reactFiber, which are undocumented but stable across React 17-18-19. If the walk fails, react-zero-skeleton falls back to a single root bone silently.
wave / shiver require a gradient peer on React Native: without expo-linear-gradient or react-native-linear-gradient, they fall back to a solid placeholder.
Animations run on the JS thread: all React Native animations use the Animated API. Reanimated worklets are on the roadmap for long lists on low-end devices.
npm install
npm run build
npm testOpen a PR against main with a changeset:
npx changesetMIT © J-Ben