The most common piece of feedback I get when I put a freshly built Rork app in front of testers is: "those first few seconds after launch feel a little uncertain." The app is working. Data is loading. But a blank screen with a spinner in the middle makes people wonder whether something is stuck.
Skeleton screens are the classic fix for that feeling. They don't actually shorten the load time, but they consistently make users stay longer and complain less. In this post I want to walk through the exact patterns I use in Rork-generated React Native projects — small enough to drop in, but solid enough to ship.
Why ActivityIndicator alone isn't enough
The code Rork generates often falls back on ActivityIndicator, which is better than nothing but carries very little information. It just says "something is happening." Skeleton screens go further: they pre-announce the layout that is about to arrive, which helps users anchor their attention before the real content shows up.
I use a simple rule of thumb on every Rork project: any screen that may take more than a second to settle gets a skeleton. Home screens, profile pages, and lists with more than 10 visible cells benefit the most. In testing it's obvious — people describe the app as "snappier" even though the measured load time hasn't changed.
The smallest possible skeleton
Start static. A handful of grey rectangles already beats a spinner.
import { View, StyleSheet } from 'react-native';
// Minimal placeholder unit
export function SkeletonBlock({ width, height, radius = 8 }: {
width: number | string;
height: number;
radius?: number;
}) {
return (
<View
style={[
styles.block,
{ width, height, borderRadius: radius },
]}
/>
);
}
const styles = StyleSheet.create({
block: {
backgroundColor: '#e5e7eb', // equivalent to Tailwind gray-200
},
});Compose it into a profile card skeleton and you already have something much more reassuring than an empty screen:
import { View, StyleSheet } from 'react-native';
import { SkeletonBlock } from './SkeletonBlock';
export function ProfileSkeleton() {
return (
<View style={styles.card}>
<SkeletonBlock width={64} height={64} radius={32} />
<View style={styles.body}>
<SkeletonBlock width="70%" height={16} />
<View style={{ height: 8 }} />
<SkeletonBlock width="40%" height={12} />
</View>
</View>
);
}
const styles = StyleSheet.create({
card: { flexDirection: 'row', padding: 16, alignItems: 'center' },
body: { flex: 1, marginLeft: 12 },
});That's it. No animation yet, but the blank screen is gone and users now see a layout preview. Honestly, for many Rork apps I stop here on the first iteration and only add motion if testing shows it's worth it.
Adding a shimmer effect without killing performance
Once static placeholders feel too still, add a shimmer — a highlight that sweeps across each block. React Native Reanimated 3 is the right tool because the animation runs on the UI thread instead of fighting the JS bridge.
import { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
Easing,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
// Skeleton block with a moving highlight
export function ShimmerBlock({ width, height, radius = 8 }: {
width: number;
height: number;
radius?: number;
}) {
const translateX = useSharedValue(-width);
useEffect(() => {
// Loop from -width to +width every 1.2 seconds
translateX.value = withRepeat(
withTiming(width, { duration: 1200, easing: Easing.linear }),
-1,
false,
);
}, [width]);
const style = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
return (
<View style={[styles.container, { width, height, borderRadius: radius }]}>
<Animated.View style={[StyleSheet.absoluteFill, style]}>
<LinearGradient
colors={['transparent', 'rgba(255,255,255,0.6)', 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: {
backgroundColor: '#e5e7eb',
overflow: 'hidden', // clip the highlight inside rounded corners
},
});Three details matter: keep the animation on the UI thread via useSharedValue and withRepeat, fade the gradient to transparent on both ends so the highlight slides in cleanly, and set overflow: 'hidden' so the shimmer respects your border radius. Skip any of those and you get jagged corners or janky motion.
If you want to go deeper on animation techniques, I covered the broader patterns in Advanced Animation Patterns with Rork, Reanimated and Gesture Handler.
Lists need skeletons that match reality
The most common mistake I see in other people's Rork apps is skeleton lists with too few items. If the actual list shows seven cells but the skeleton only has three, the screen visibly jumps down when the real data lands and users lose their place.
A few guidelines that keep list skeletons feeling trustworthy:
- Render at least as many skeleton rows as can fit on screen — a quick estimate is
flatListHeight / itemHeightplus a little extra. - Match the real row height exactly. Being off by even 4 pixels makes the transition visibly shift.
- Start from the top and preserve the real spacing between rows.
import { FlatList, View } from 'react-native';
import { SkeletonBlock } from './SkeletonBlock';
const SKELETON_ITEMS = Array.from({ length: 8 }); // fit-to-screen + buffer
export function ListSkeleton() {
return (
<FlatList
data={SKELETON_ITEMS}
keyExtractor={(_, i) => `sk-${i}`}
renderItem={() => (
<View style={{ padding: 16, flexDirection: 'row', alignItems: 'center' }}>
<SkeletonBlock width={48} height={48} radius={24} />
<View style={{ flex: 1, marginLeft: 12 }}>
<SkeletonBlock width="80%" height={14} />
<View style={{ height: 6 }} />
<SkeletonBlock width="50%" height={12} />
</View>
</View>
)}
scrollEnabled={false}
/>
);
}scrollEnabled={false} is intentional: if a user accidentally scrolls during the loading state, the cost of rendering real data when it arrives spikes on older Android hardware. That one line alone has saved me from janky hand-offs more than once.
Switching from skeleton to real data cleanly
The hand-off between the skeleton and the real content is where most flicker creeps in. Two techniques I reach for on every project:
1. Enforce a minimum display time. If data arrives in 40 ms, the skeleton flashes for a single frame and looks like a glitch. Holding it visible for at least 200 to 300 ms smooths things out.
import { useEffect, useState } from 'react';
export function useDelayedReady(ready: boolean, minMs = 250) {
const [show, setShow] = useState(ready);
useEffect(() => {
if (!ready) {
setShow(false);
return;
}
const id = setTimeout(() => setShow(true), minMs);
return () => clearTimeout(id);
}, [ready, minMs]);
return show;
}Wire it up like this:
const { data, isLoading } = useProfile();
const ready = useDelayedReady(!isLoading);
return ready ? <Profile data={data} /> : <ProfileSkeleton />;2. Fade the real view in. Wrap the real content in an Animated.View with a 150 ms fade-in. The cost is negligible and the visual seam between skeleton and data softens noticeably.
Pitfalls worth avoiding
A few mistakes I've had to flag during code review:
- Forgetting image placeholders. Text gets a skeleton, but the image slot does not — then the image pops in and the layout jumps. Lock
widthandheighton theImageparent and render a matching skeleton there. - Overusing shimmer. Running 50+ simultaneous shimmer animations on older Android devices drops frames. For lists, only animate currently visible cells;
FlatList'sonViewableItemsChangedis perfect for this. - Using brand colors for skeletons. A soft version of your brand color looks "designed" but can be confused with real content. Stay with neutral grays — something around
#2a2a2afor dark mode — and let the real UI stand out when it arrives.
The sibling topic of handling bad connectivity is in Designing for Flaky Networks in Rork Apps — UX and Error States. Skeletons and error states are two sides of the same coin, so they pair well.
What to try next
You don't need to retrofit every screen in a day. Pick your home screen and your most data-heavy list, replace the spinner with a skeleton, and run one round of tester feedback. That alone is usually enough to convince you to keep going.
If you want to widen the lens from loading states to UX patterns as a whole, I put together The Rork App UX Design Patterns Complete Guide for that. Thanks for reading — I hope your next build feels a little kinder to the people using it.