anim
color
Quick StartHow It WorksAtomsMoleculesListsScreensPatternsAPI Reference
Getting started

Quick Start

One package, zero native code. The bundler picks the right build automatically — Metro for React Native, Webpack/Vite for web.

1. Install

npm install react-zero-skeleton
# or
yarn add react-zero-skeleton

2. Wrap your component

import { withSkeleton } from 'react-zero-skeleton'
function ArticleCard({ article }) {
return (
<div>
<img src={article.cover} style={{ height: 160 }} />
<p style={{ fontWeight: 600 }}>{article.title}</p>
<p style={{ color: '#888' }}>{article.excerpt}</p>
</div>
)
}
// Step 1 — wrap once
export default withSkeleton(ArticleCard)

3. Pass two props

// Step 2 — two props wherever you use it
<ArticleCard hasSkeleton isLoading={isLoading} article={data} />
// Shorthand — activates hasSkeleton + isLoading at once
<ArticleCard isLoadingSkeleton article={data} />

4. (Optional) Global theme

import { SkeletonTheme } from 'react-zero-skeleton'
// Step 3 (optional) — global config for your whole app
export default function App() {
return (
<SkeletonTheme
animation="wave"
color="#27272a"
highlightColor="#3f3f46"
borderRadius={6}
>
<YourApp />
</SkeletonTheme>
)
}

Animations

// Per-component override
<ArticleCard
hasSkeleton
isLoading={isLoading}
skeletonConfig={{ animation: 'shatter' }}
article={data}
/>
// Available animations
// 'pulse' — soft opacity fade (default)
// 'wave' — shimmer left to right
// 'shiver' — intense wave, wider amplitude
// 'shatter' — grid fragmentation with stagger
// 'none' — static placeholder
React Native only: wave and shiver require a LinearGradient peer — install expo-linear-gradient or react-native-linear-gradient. Both are auto-detected, no config needed. On web, CSS gradients are used instead.
Under the hood

How skelter works

Most skeleton libs ask you to describe the layout manually. skelter does the opposite: it renders your real component, measures it live, and generates bones from that. Here is exactly what happens under the hood.

1

You wrap your component once

withSkeleton is a Higher-Order Component. It injects three props into your component: hasSkeleton, isLoading, and skeletonConfig. Your component itself does not change at all.

// You write your component once, as usual.
function ArticleCard({ article }) {
return (
<div>
<img src={article.cover} style={{ height: 160, borderRadius: 10 }} />
<p style={{ fontWeight: 600 }}>{article.title}</p>
<p style={{ color: '#888' }}>{article.excerpt}</p>
</div>
)
}
// One line. That's it.
export default withSkeleton(ArticleCard)
2

Invisible warmup render

When isLoading is true, skelter renders your component completely hidden (visibility: hidden on web, opacity: 0 on native). This warmup gives the browser and native runtime a chance to lay out all elements with real dimensions before measuring anything.

// When isLoading is true, skelter renders your component
// invisibly (visibility: hidden on web, opacity: 0 on native).
// It uses your real props — or mockProps on cold start.
<ArticleCard hasSkeleton isLoading={true} article={data} />
// real component renders here, completely hidden
// ↓
// [ img 320x160 · borderRadius 10 ]
// [ p "Article title" 200x18 ]
// [ p "Excerpt text..." 180x14 ]
COLD START
If your data is null on first mount (common with async fetching), the component may render nothing, so there is nothing to measure. This is where mockProps comes in. See the Cold Start section below for details.
3

Layout measurement

After the warmup render, skelter reads the actual layout of every element. The strategy differs by platform but the output is the same: a flat list of positioned bones.

Web

ResizeObserver fires on each element. getBoundingClientRect() gives position and size. getComputedStyle() reads the computed border-radius. Bones reflow automatically whenever the container resizes.

React Native

skelter reads _reactInternals / _reactFiber from the root native View, then walks the Fiber tree depth-first. Each leaf node is measured with stateNode.measure(). Falls back to root-only if fibers are inaccessible.

// Web: ResizeObserver fires on every element.
// skelter reads getBoundingClientRect() + getComputedStyle()
// for each DOM node — position, size, borderRadius.
// React Native: skelter walks the React Fiber tree.
// _reactInternals / _reactFiber -> stateNode.measure()
// One async call per leaf node.
// Both paths produce the same result:
const bones = [
{ x: 0, y: 0, width: 320, height: 160, borderRadius: 10 }, // img
{ x: 0, y: 168, width: 200, height: 18, borderRadius: 4 }, // title
{ x: 0, y: 190, width: 180, height: 14, borderRadius: 4 }, // excerpt
]
4

Bones generated, always in sync

Bones are absolutely positioned over the hidden component. When isLoading flips to false, the real component takes over. No transition needed. Because measurement is live, any layout change in your component is automatically reflected in the skeleton the next time it shows.

// skelter renders the bones on top of the hidden component.
// When isLoading flips to false, the real component fades in.
// The skeleton always matches your real component.
// Resize the window — bones reflow automatically (ResizeObserver).
// Change your layout — nothing to update. It just works.
VS OTHER LIBS
Other libraries give you a <SkeletonPlaceholder> that you fill manually. When your design changes, you update the real component and forget to update the skeleton. They drift apart. With skelter you write the component once and you are done forever.
Cold start

The blank screen problem, solved

Your data arrives asynchronously. On first mount, article is null. Your component renders nothing. skelter tries to measure an empty container and falls back to a single gray block. Not what you want.

WITHOUT MOCKPROPS

// The problem: your component with null data renders nothing.
// skelter tries to measure... an empty div.
function ArticleCard({ article }) {
if (!article) return null // <-- skelter sees nothing
return (
<div>
<img src={article.cover} />
<p>{article.title}</p>
</div>
)
}
// Result: a single fallback bone the size of the container.
// Not ideal.

WITH MOCKPROPS

// The fix: mockProps gives skelter realistic fake data
// for the invisible warmup render only.
export default withSkeleton(ArticleCard, {
mockProps: {
article: {
cover: null, // image src can be null, size is what matters
title: 'Lorem ipsum', // any string — real width will be measured
excerpt: 'Dolor sit amet consectetur adipiscing elit.',
}
}
})
// What happens:
// 1. isLoading = true
// 2. skelter renders ArticleCard with { ...realProps, ...mockProps }
// 3. Fiber walk / ResizeObserver measures the realistic layout
// 4. Perfect bones, even before your data arrives
// 5. mockProps are never used again after first measurement
TIP
Your mock values do not need to match real data exactly. skelter only cares about the rendered dimensions. A title of "Lorem ipsum" gives a bone roughly the same width as a real title. Images can be null as long as the element has an explicit height in its style.
Atoms

Simple components

Atomic components are the smallest UI elements. withSkeleton wraps anything — one element, one bone, always perfectly sized.

Avatar

A circular profile picture. borderRadius: 50% is read from the element's style automatically — the bone is always a perfect circle.

Image Block

A hero or cover image. The bone inherits exact dimensions and border radius from your img / Image element.

Text Lines

Multiple text elements in a stack. skelter walks the tree and creates one bone per text node — each bone has the real measured width of that line.

Badges / Tags

Small pill-shaped labels. Every badge becomes its own bone, width and border-radius preserved.

Molecules

Composed components

Molecules combine multiple atoms into a meaningful unit. skelter walks the entire component tree — one bone per element, always in sync with your real layout.

Profile Card

Avatar + name + role + bio. The fiber walk generates 4 bones: the avatar circle, and one bone per text node — each with its real measured width.

Article Card

Cover image + metadata + title + excerpt. Each element is measured independently — resize the window and bones reflow with your component.

Comment

Small avatar + author name + timestamp + body text. A common pattern in feeds and discussions — works identically in web and React Native.

Notification Item

Icon + title + meta + unread dot. The small dot on the right becomes its own perfectly circular bone.

Lists

Repeated items

Map over placeholder data and pass isLoadingSkeleton per item. On React Native, skelter auto-detects FlatList context and switches to root-only mode for smooth scrolling.

Article List

Four article rows with thumbnail, category, title, and meta. Each row is independently wrapped — skeletons can stagger their reveal as data arrives.

User List

Five user rows with avatar, online indicator, name, role, and a follow button. The small online dot becomes its own circular bone.

Array.map()

The simplest pattern — map over a placeholder array of nulls and pass isLoadingSkeleton per item. Works everywhere: web, ScrollView, any custom container.

FlatList

Drop-in for React Native FlatList. Pass null items while loading — skelter auto-detects the VirtualizedList context and switches to root-only mode for smooth 60fps scrolling.

FlashList

Same API with @shopify/flash-list — the fastest list renderer for React Native. Provide estimatedItemSize as usual, skelter handles the rest.

Screens

Full-page layouts

Wrap entire screens with withSkeleton. Use mockProps so the Fiber walk always has a realistic layout to measure — even before your data arrives.

Profile Screen

Overlapping avatar + banner + bio + stats row + posts grid. One withSkeleton call, ~15 bones generated automatically — no maintenance.

Dashboard / Stats Screen

Header + 2x2 stat grid + activity feed. The mockProps option pre-populates realistic data shapes so the skeleton always has correct dimensions on cold start.

Integration patterns

Patterns

skelter is not tied to any data-fetching library. It only cares about one boolean: is the data ready? Below are recipes for the most common patterns.

TanStack Query

React Query integration

useQuery returns isLoading — pass it straight to the component. Add mockProps once so the skeleton looks right even before the first fetch completes.

WRAP ONCE

import { withSkeleton } from 'react-zero-skeleton'
function ArticleCard({ article }) {
return (
<div>
<img src={article.cover} style={{ height: 160 }} />
<p style={{ fontWeight: 600 }}>{article.title}</p>
<p style={{ color: '#888' }}>{article.excerpt}</p>
</div>
)
}
export default withSkeleton(ArticleCard, {
mockProps: {
article: {
cover: null,
title: 'Lorem ipsum dolor sit amet',
excerpt: 'Consectetur adipiscing elit sed do eiusmod.',
},
},
})

USE ANYWHERE

import { useQuery } from '@tanstack/react-query'
import ArticleCard from './ArticleCard'
export function ArticleDetail({ id }) {
const { data, isLoading } = useQuery({
queryKey: ['article', id],
queryFn: () => fetchArticle(id),
})
// isLoading maps directly to isLoading on the component.
// When data is undefined, mockProps provides the warmup layout.
return (
<ArticleCard
hasSkeleton
isLoading={isLoading}
article={data}
/>
)
}

Lists with React Query

import { useQuery } from '@tanstack/react-query'
import { SkeletonTheme } from 'react-zero-skeleton'
import ArticleCard from './ArticleCard'
export function ArticleFeed() {
const { data = [], isLoading } = useQuery({
queryKey: ['articles'],
queryFn: fetchArticles,
})
// Show 3 skeletons while loading, real cards when done.
const items = isLoading ? Array(3).fill(null) : data
return (
<SkeletonTheme animation="wave" color="#27272a" highlightColor="#3f3f46">
{items.map((article, i) => (
<ArticleCard
key={article?.id ?? i}
hasSkeleton
isLoading={isLoading}
article={article}
style={{ marginBottom: 16 }}
/>
))}
</SkeletonTheme>
)
}
PATTERN
Render Array(n).fill(null) as placeholder items while isLoading is true. Each null item gets a skeleton. When the real data arrives, React reconciles by key and swaps them in. The transition is instant.
SWR

SWR integration

SWR separates initial load (isLoading) from background refresh (isValidating). Pick whichever makes sense for your UX.

BASIC

import useSWR from 'swr'
import ProfileCard from './ProfileCard'
export function UserProfile({ username }) {
const { data, isLoading } = useSWR(
`/api/users/${username}`,
fetcher,
)
return (
<ProfileCard
hasSkeleton
isLoading={isLoading}
user={data}
/>
)
}

STALE-WHILE-REVALIDATE

import useSWR from 'swr'
import ArticleCard from './ArticleCard'
export function CachedArticle({ id }) {
// SWR: isLoading = true only on the very first fetch (no cache).
// isValidating = true on every background refresh.
// skelter can track either — your call.
const { data, isLoading, isValidating } = useSWR(
`/api/articles/${id}`,
fetcher,
)
return (
<ArticleCard
hasSkeleton
isLoading={isLoading} // skeleton only on cold load
// isLoading={isValidating} // skeleton on every refresh
article={data}
/>
)
}
NOTE
isLoading in SWR v2 is true only when there is no cached data yet.isValidating is true on every request, including re-fetches. Most UIs should use isLoadingto avoid re-showing the skeleton on every focus event.
React Native

FlatList + infinite scroll

FlatList with TanStack Query. Skeleton rows show on the initial load, disappear when data arrives, and never block scroll performance.

import { useInfiniteQuery } from '@tanstack/react-query'
import { FlatList } from 'react-native'
import { SkeletonTheme } from 'react-zero-skeleton'
import PostCard from './PostCard'
export function PostFeed() {
const { data, isLoading, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
getNextPageParam: (last) => last.nextCursor,
})
const posts = data?.pages.flatMap(p => p.items) ?? []
const items = isLoading ? Array(4).fill(null) : posts
return (
<SkeletonTheme animation="pulse" color="#27272a">
<FlatList
data={items}
keyExtractor={(item, i) => item?.id ?? String(i)}
renderItem={({ item, index }) => (
<PostCard
hasSkeleton
isLoading={isLoading}
post={item}
/>
)}
onEndReached={() => hasNextPage && fetchNextPage()}
onEndReachedThreshold={0.3}
/>
</SkeletonTheme>
)
}
PERF
Components inside FlatList automatically switch to root-only measurement mode. This means one bone per row instead of one per element. It is intentional: walking the Fiber tree for every row in a 1000-item list would be too slow. Use maxBonesInList to cap the number of skeleton rows if needed.
Tips

Tuning and edge cases

Avoid a flash with minDuration

// Avoid a flash when data loads faster than your eye can register.
// minDuration holds the skeleton for at least 600ms.
<ArticleCard
hasSkeleton
isLoading={isLoading}
skeletonConfig={{ minDuration: 600 }}
article={data}
/>

If your API responds in under 100ms the skeleton might flash for just one frame.minDuration: 600 holds it for at least half a second.

Exclude heavy native views

// MapView and VideoPlayer have their own loading states.
// Tell skelter to skip them during Fiber tree measurement.
export default withSkeleton(ProfileScreen, {
measureStrategy: 'auto',
exclude: ['MapView', 'VideoPlayer'],
mockProps: {
user: { name: 'Jane Doe', bio: 'Product designer', followerCount: 1200 },
},
})

Components like MapView or VideoPlayer have native backing views that either fail to measure or produce confusing bones. Pass their displayName to exclude.

Reference

API Reference

Config priority: skeletonConfig propSkeletonTheme defaults.

Props injected by withSkeleton

PropTypeDefaultDescription
hasSkeletonbooleanActivates skeleton mode on this component.
isLoadingbooleanShows skeleton when true, real component when false.
isLoadingSkeletonbooleanShorthand — sets both hasSkeleton and isLoading to true.
skeletonConfigSkeletonConfigPer-instance config. Highest priority in the chain.

SkeletonConfig

PropTypeDefaultDescription
animation'pulse' | 'wave' | 'shiver' | 'shatter' | 'none''pulse'Animation style.
colorstring'#E0E0E0'Base bone color.
highlightColorstring'#F5F5F5'Shimmer highlight color (wave / shiver).
speed'slow' | 'normal' | 'rapid' | number'normal'0.5× / 1× / 2× or custom multiplier.
borderRadiusnumber4Fallback radius when element has no explicit style.
direction'ltr' | 'rtl''ltr'Shimmer direction for RTL layouts.
minDurationnumber0Minimum ms the skeleton stays visible.
disabledbooleanfalseNever show skeleton when true.
maxBonesInListnumber0Max bones in FlatList (0 = unlimited).
shatterConfigShatterConfigGrid fragmentation options (see below).

ShatterConfig

PropTypeDefaultDescription
gridSizenumber6Number of columns in the grid.
staggernumber80Delay in ms between each square.
fadeStyle'random' | 'cascade' | 'radial''random'Square fade order.

withSkeleton(Component, options?) — second argument

PropTypeDefaultDescription
measureStrategy'auto' | 'root-only''auto''auto' walks the Fiber tree — one bone per element. 'root-only' restores single-block behaviour.
maxDepthnumber8Max depth of the Fiber tree traversal.
excludestring[][]Component displayNames to skip. e.g. ['MapView', 'VideoPlayer'].
mockPropsRecord<string, unknown>{}Props used for the invisible warmup render on cold start.

Platform notes

React Native only: auto mode walks _reactInternals / _reactFiber — stable across React 17–18. Falls back to root-only if fibers are inaccessible.

Web only: uses ResizeObserver for continuous measurement — bones reflow automatically when the container is resized.

FlatList: components inside FlatList / FlashList auto-switch to root-only mode. shatter falls back to pulse silently for performance.

MIT License · github.com/J-Ben/skelter