react-native-starlight-skeleton
v0.1.0
Published
Zero-layout-shift skeleton loader for React Native. Traverses your existing view tree with React.Children, reads each node's StyleSheet dimensions, and swaps leaves for shimmering Animated.View placeholders that match the final UI exactly.
Maintainers
Readme
react-native-starlight-skeleton
Zero-layout-shift skeleton loader for React Native. Wrap your real UI in
<StarlightSkeleton loading> — it traverses the children tree, reads each
node's StyleSheet dimensions, and swaps leaves for shimmering Animated.View
placeholders that occupy exactly the boxes the final components will.
Features
- Zero layout shift — placeholders are sized from your actual styles, so the transition from skeleton to content never reflows.
- No skeleton markup to maintain — the loading state is derived from the same JSX as the loaded state; change the card, the skeleton follows.
- One shared native animation — a single looping
Animated.Value(useNativeDriver: true) drives every bone's opacity pulse in sync. - Zero runtime dependencies — only
reactandreact-nativepeers. - Gradient-ready — inject any highlight component (e.g.
react-native-linear-gradient) into every bone via one prop. - TypeScript-first, ESM + CJS builds, works on iOS and Android.
Install
npm install react-native-starlight-skeleton
# or
yarn add react-native-starlight-skeletonPeer requirements: react 18 or 19, react-native >= 0.72.
Quickstart
import { useEffect, useState } from 'react';
import { Image, StyleSheet, Text, View } from 'react-native';
import { StarlightSkeleton } from 'react-native-starlight-skeleton';
export function ProfileCard() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
return (
<StarlightSkeleton loading={user === null}>
<View style={styles.card}>
<Image source={{ uri: user?.avatar }} style={styles.avatar} />
<View style={styles.info}>
<Text style={styles.name}>{user?.name}</Text>
<Text style={styles.bio}>{user?.bio}</Text>
</View>
</View>
</StarlightSkeleton>
);
}
const styles = StyleSheet.create({
card: { flexDirection: 'row', gap: 12, padding: 16 },
avatar: { width: 48, height: 48, borderRadius: 24 },
info: { flex: 1, gap: 6 },
name: { fontSize: 16, lineHeight: 22, fontWeight: '600' },
bio: { fontSize: 13, lineHeight: 18, color: '#666' },
});While loading is true, the card renders as a row with a 48×48 circular bone
and two text bars sized from your lineHeights. When it flips to false, your
children render untouched — no wrapper, no cloned props.
A full item-card feed demo lives in
example/FeedCardListScreen.tsx.
How it works
Traversal
When loading is true, the tree is compiled once per render with
React.Children.map:
- Leaves become bones.
Text,Image, andTextInputelements (detected by component identity, with a displayName fallback), bare strings/numbers, and any element without children are replaced by aShimmerBonesized from their style. - Containers are cloned. A
View(or any element) with children is cloned viacloneElement, keeping its layout style, and its children are traversed recursively. - Depth is capped. Past
maxDepth(default 10) a subtree collapses into a single bone.
Style extraction — why nothing shifts
Each node's style is run through StyleSheet.flatten and split:
- Container styles (
flexDirection,gap,padding*,alignItems,justifyContent,flexWrap) stay on the cloned container, so bones are laid out exactly where real children will sit. Containers also keep their own size/margin/flex box. - Size styles (
width,height, min/max,aspectRatio,flex*,margin*,alignSelf,borderRadius) move onto the bone, so each placeholder occupies precisely the final component's box. - Text without an explicit height derives one:
lineHeightif set, otherwiseround(fontSize × 1.25)(React Native's defaultfontSizeis 14, giving an 18pt bar). Text without an explicit width renders at'70%'so it reads as a line of text rather than a full-bleed block. - Images and explicitly sized nodes use their
width/heightdirectly.
Because the placeholder geometry is derived from the same StyleSheet as the
real UI, swapping loading causes zero layout shift.
Animation
The root creates one Animated.Value and runs
Animated.loop(Animated.sequence([timing 0→1, timing 1→0])) with
useNativeDriver: true. Every bone interpolates that shared value into an
opacity pulse (0.45 ↔ 1) over its solid baseColor — smooth and uniform on
both iOS and Android, and off the JS thread. The loop stops and resets when
loading turns off or the skeleton unmounts. The skeleton root is inert
(pointerEvents="none") and announced to screen readers as
accessibilityLabel="Loading".
Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| loading | boolean | — (required) | true renders the skeleton; false renders children untouched. |
| children | ReactNode | — (required) | Your real UI. |
| baseColor | string | '#E1E4EA' | Solid bone color. |
| shimmerColor | string | '#F2F4F8' | Highlight color, forwarded to HighlightComponent. |
| animationDuration | number | 1200 | Full pulse cycle in ms (0→1→0). |
| borderRadius | number | 6 | Fallback bone radius when a node's style declares none. |
| maxDepth | number | 10 | Max container recursion depth; deeper subtrees collapse to one bone. |
| HighlightComponent | ComponentType<ShimmerHighlightProps> | — | Rendered inside every bone; receives progress, baseColor, shimmerColor. |
Also exported: ShimmerBone (aliased as Bone) for hand-rolled skeletons,
StarlightContext, and all public types.
Gradient shimmer injection
The package ships dependency-free, but every bone will render an injected
highlight — the classic sweeping-gradient look with
react-native-linear-gradient:
import { Animated, StyleSheet } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import {
StarlightSkeleton,
type ShimmerHighlightProps,
} from 'react-native-starlight-skeleton';
const AnimatedGradient = Animated.createAnimatedComponent(LinearGradient);
function GradientHighlight({ progress, shimmerColor }: ShimmerHighlightProps) {
const translateX = progress
? progress.interpolate({ inputRange: [0, 1], outputRange: [-200, 200] })
: 0;
return (
<AnimatedGradient
colors={['transparent', shimmerColor, 'transparent']}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={[StyleSheet.absoluteFill, { transform: [{ translateX }] }]}
/>
);
}
<StarlightSkeleton loading HighlightComponent={GradientHighlight}>
{/* ... */}
</StarlightSkeleton>;Limitations
- Custom components are treated as containers. Traversal sees elements,
not rendered output: a custom component with
childrenis cloned and its children traversed; one withoutchildrenbecomes a single bone sized from itsstyleprop. If a custom component renders its own internals, wrap the skeleton around its output styles or give it explicit dimensions. - Dynamic widths are approximated. Text measured at runtime (no explicit
width/flex) falls back to a'70%'bar; content-drivenImagesizes (no style dimensions) produce a zero-height bone — always give skeletonized images explicit dimensions oraspectRatio. - Percentages resolve against the skeleton's container, which is the cloned original container — identical box, so this is normally invisible.
- Styles supplied through mechanisms other than the
styleprop (e.g.className-based systems that inject styles at render time) are not seen by extraction.
License
MIT © 2026 Dinesh Kumar
