Skip to content

J-Ben/skelter

react-zero-skeleton

npm version npm downloads bundle size TypeScript React Native React MIT License

→ Live demo · Docs

skelter paysage

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} />

Installation

npm install react-zero-skeleton

No 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)

How it works

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.


Quick start

Wrap once

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)

Use it

// Two props. That's it.
<ProfileCard hasSkeleton isLoading={isLoading} user={data} />

// Shorthand: activates hasSkeleton + isLoading at once
<ProfileCard isLoadingSkeleton user={data} />

Global theme

import { SkeletonTheme } from 'react-zero-skeleton'

<SkeletonTheme animation="wave" color="#E0E0E0" highlightColor="#F5F5F5">
  <App />
</SkeletonTheme>

Config priority: skeletonConfig prop > SkeletonTheme > defaults.


Animations

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

wave and shiver require a LinearGradient peer on React Native: npx expo install expo-linear-gradient or npm install react-native-linear-gradient. On web, CSS gradients are used: no peer needed.

Cascade

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>

Shatter

<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'
    }
  }}
/>

Loading experience

The transition into and out of the skeleton is part of the experience. react-zero-skeleton gives you control over both.

Enter animation

Played when the skeleton first appears.

<SkeletonTheme enter="fadeUp"></SkeletonTheme>
// 'fade' | 'fadeUp' | 'fadeDown' | 'fadeLeft' | 'fadeRight' | 'none'

Exit animation

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.


SkeletonBox

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.


SkeletonIgnore

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.


SkeletonParagraph

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>
  )
}

Line count

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 */}

Alignment

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, textAlign lives on Text, not View. To inherit alignment, set textAlign on the SkeletonParagraph style, or pass the align prop (recommended for cross-platform).

Word mode

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>

Props

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).


Adaptive animation

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.

How it works

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' },
  ]}
>

Rule matrix (declarative)

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' },
]}

Function (escape hatch)

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
}}

Per-element override

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>

conditions fields

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

API Reference

Props added by withSkeleton

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

SkeletonConfig

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)

withSkeleton(Component, options?)

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

mockProps: cold start

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 } }
})

ShatterConfig

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

registerSkeletonLeaf (React Native)

Registers custom image components as leaf elements in the Fiber walk:

import { registerSkeletonLeaf } from 'react-zero-skeleton'
registerSkeletonLeaf('FastImage', 'ExpoImage')

SkeletonTheme auto mode (React Native)

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} />

FlatList

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.


Accurate bones: fit-content

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>

Comparison

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

Limitations

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.


Contributing

npm install
npm run build
npm test

Open a PR against main with a changeset:

npx changeset

License

MIT © J-Ben

About

Stop writing skeleton loaders. Auto-generated from your component layout.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors