Shipnative
ShipnativeShipnative
Sign in
GuideAugust 2026 · 8 min read

React Native Skeleton Loader: Build One That Helps

A skeleton loader is a small promise: content is coming, and it will look like this. Kept, it makes a two-second wait feel like half of one. Broken — a shimmer that flashes for 40 ms, or placeholder blocks that sit nowhere near where the real content lands — it is measurably worse than the spinner it replaced. This guide builds a reusable one with Reanimated, compares the library options honestly, and covers the timing rules that separate the two outcomes.

The four options

ApproachNeedsExpo GoBest forCost
Hand-rolled + Reanimatedreact-native-reanimated✅ YesMatching your own design tokens exactlyYou write ~30 lines
moti/skeletonmoti + reanimated + linear-gradient✅ YesA gradient shimmer with no animation codeThree dependencies for one visual
react-content-loaderreact-native-svg✅ YesIrregular shapes drawn as SVGPlaceholders live outside your layout system
No skeleton at all—✅ YesCached data that resolves in under ~150 msNothing — 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.

Frequently Asked Questions

How do I create a skeleton loader in React Native?

Render grey placeholder blocks shaped like the real content, then animate a highlight across them. The lightest version is a View with a background colour and a Reanimated shared value driving opacity in a loop — around 30 lines and no dependencies beyond react-native-reanimated. A shimmer that sweeps across needs one extra layer: an absolutely positioned gradient translated along the X axis.

Are skeleton screens better than a spinner?

For content that has a predictable shape — a feed, a profile, a list of cards — yes, because the layout does not jump when data arrives and the wait feels shorter. For an action with an unknown result, such as submitting a form or running a search, a spinner is more honest: a skeleton promises a shape you cannot guarantee.

What is the best React Native skeleton library?

moti/skeleton if you already use Moti or Reanimated, since it handles the gradient animation and dark mode with a single component. react-content-loader gives you SVG-defined shapes and is a good fit when the placeholder is irregular. For most apps the honest answer is neither — a 30-line local component matches your design system better than any library default and adds no dependency.

Why does my skeleton flash on screen and disappear instantly?

Because the data was cached and resolved in 40 ms. A placeholder that appears and vanishes faster than the eye can settle reads as a glitch. Fix it with two thresholds: do not show the skeleton until loading has run for about 150 ms, and once shown, keep it for a minimum of about 300 ms. The result is that fast loads show nothing at all and slow loads show a stable skeleton.

Do skeleton loaders hurt performance?

They can if the animation runs on the JavaScript thread — which is exactly when the thread is busy parsing the response you are waiting for. Drive the animation with Reanimated so it runs on the UI thread, and animate opacity or transform only. Animating width, height, or backgroundColor per frame forces layout work on every tick.

How many skeleton rows should I render?

Enough to fill the visible screen and no more — typically five to eight list rows. Rendering twenty placeholder rows for a list that will return three is both wasted work and a small lie about what is coming. Match the count to what a full first screen looks like.

→

React Native FlatList: The Props That Matter

Where the skeleton rows go, and how to keep the list smooth after they leave.

Read guide →
→

React Native Animations: Reanimated Guide

The UI-thread rule that keeps a shimmer smooth while data parses.

Read guide →

Ship a real React Native app today

Describe, preview, and export Expo code — free to start.

Build with ShipNative →
ShipNative logoShipnative

Build mobile apps with AI. Describe, preview, and ship to iOS & Android in minutes.

Features

Text to App AIApp Generator from ScreenshotPRD to Mobile App

Tools

All free toolsApp Cost CalculatorApp Name GeneratorApp Store Keyword ToolReact Native Components

Blog

All blog postsHow to Build an App Without CodingBest AI Tools for Real Mobile AppsExpo EAS App Store ChecklistLovable, Cursor & v0 for MobileBest AI App Builders in 2026React Native AI App Builder Guide

Legal

FAQTerms of ServicePrivacy Policy

© 2026 ShipNative. All rights reserved.