Skeleton Loading Animation Implementation in Mobile Apps
Skeleton loading displays placeholder shapes showing content structure while data loads. Gray rectangles, sometimes with shimmer or pulsing animation, reduce perceived wait time—users see screen structure immediately rather than a blank white screen with a spinner.
Correct skeleton isn't just "gray blocks"—its shape precisely mirrors future content, shimmer moves in one direction across the entire screen (not separately in each block), and the transition to real content is smooth.
Android: Shimmer via Facebook Library or Custom Drawable
The easiest approach uses the com.facebook.shimmer:shimmer:0.5.0 library:
<com.facebook.shimmer.ShimmerFrameLayout
android:id="@+id/shimmerContainer"
app:shimmer_duration="1200"
app:shimmer_angle="20"
app:shimmer_base_alpha="0.7"
app:shimmer_highlight_alpha="1.0">
<!-- Layout copy of real content with placeholders -->
<include layout="@layout/skeleton_user_card" />
</com.facebook.shimmer.ShimmerFrameLayout>
In code:
shimmerContainer.startShimmer()
// On load completion:
shimmerContainer.stopShimmer()
shimmerContainer.visibility = View.GONE
realContentView.visibility = View.VISIBLE
The advantage: shimmer is synchronized across all skeleton blocks through one ShimmerFrameLayout. Disadvantage: extra dependency; ShimmerFrameLayout recalculates bounds each frame—on complex layouts this is noticeable.
Custom approach using AnimatedVectorDrawable with gradient animation as background:
<!-- res/drawable/skeleton_shimmer.xml -->
<animated-vector xmlns:android="..." xmlns:aapt="...">
<aapt:attr name="android:drawable">
<vector android:width="400dp" android:height="50dp" android:viewportWidth="400" android:viewportHeight="50">
<path android:fillType="evenOdd"
android:pathData="M0,0 L400,0 L400,50 L0,50 Z"
android:fillColor="#E0E0E0" />
</vector>
</aapt:attr>
<!-- animator for gradient offset -->
</animated-vector>
In Compose, use InfiniteTransition for shimmer:
@Composable
fun ShimmerBox(modifier: Modifier = Modifier) {
val shimmerColors = listOf(
Color.LightGray.copy(alpha = 0.6f),
Color.LightGray.copy(alpha = 0.2f),
Color.LightGray.copy(alpha = 0.6f),
)
val transition = rememberInfiniteTransition(label = "shimmer")
val translateAnim by transition.animateFloat(
initialValue = 0f,
targetValue = 1000f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = FastOutSlowInEasing),
),
label = "shimmer_translate"
)
val brush = Brush.linearGradient(
colors = shimmerColors,
start = Offset(translateAnim - 500f, 0f),
end = Offset(translateAnim, 0f)
)
Box(modifier = modifier.background(brush, RoundedCornerShape(4.dp)))
}
Shimmer via Brush.linearGradient with changing start/end is a GPU operation through graphicsLayer, causing no recomposition of content.
iOS: UIKit and SwiftUI
In UIKit, use CAGradientLayer with CABasicAnimation:
func addShimmerAnimation(to view: UIView) {
let gradientLayer = CAGradientLayer()
gradientLayer.frame = CGRect(x: -view.bounds.width, y: 0,
width: view.bounds.width * 3, height: view.bounds.height)
gradientLayer.colors = [
UIColor.systemGray5.cgColor,
UIColor.systemGray6.cgColor,
UIColor.systemGray5.cgColor
]
gradientLayer.locations = [0, 0.5, 1]
gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
view.layer.mask = gradientLayer
let animation = CABasicAnimation(keyPath: "position.x")
animation.fromValue = -view.bounds.width
animation.toValue = view.bounds.width * 2
animation.duration = 1.2
animation.repeatCount = .infinity
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
gradientLayer.add(animation, forKey: "shimmerAnimation")
}
CABasicAnimation on CALayer runs entirely on render thread—main thread doesn't participate in each animation frame.
In SwiftUI, use TimelineView (iOS 15+) or withAnimation + @State:
struct SkeletonView: View {
@State private var phase: CGFloat = 0
var body: some View {
Rectangle()
.fill(LinearGradient(
gradient: Gradient(colors: [Color(.systemGray5), Color(.systemGray6), Color(.systemGray5)]),
startPoint: .init(x: phase - 0.5, y: 0.5),
endPoint: .init(x: phase + 0.5, y: 0.5)
))
.onAppear {
withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) {
phase = 2.0
}
}
}
}
Transition from Skeleton to Content
Abrupt content appearance where skeleton was is harsh. Use smooth crossfade:
// Compose
AnimatedContent(
targetState = isLoading,
transitionSpec = { fadeIn(tween(300)) togetherWith fadeOut(tween(300)) }
) { loading ->
if (loading) SkeletonCard() else RealCard(data = data)
}
// SwiftUI
if isLoading {
SkeletonCard()
.transition(.opacity)
} else {
RealCard(data: data)
.transition(.opacity)
}
// withAnimation(.easeInOut(duration: 0.3)) { isLoading = false }
Timeline
Skeleton for one screen (list or detail) with shimmer animation: 1 day. Component system of skeletons for the entire app with transitions: 1–2 days. Cost is calculated individually.







