What You Will Build
A skeleton loading splash screen that displays placeholder shapes with a sliding shimmer highlight while content loads. When data arrives, the placeholders transition smoothly into real content. This is the loading pattern used by Facebook, LinkedIn, and YouTube to keep users engaged during network fetches.
Why Skeleton Screens Beat Spinners
Research shows skeleton screens feel faster than spinning indicators because they preview the layout structure. Users perceive the app as partially loaded rather than completely blocked. SwiftUI makes this easy with animated gradient overlays and a simple boolean toggle.
Key SwiftUI Concepts
- LinearGradient overlay creates the shimmer highlight sliding across each placeholder.
- @State offset animated with
.repeatForeverdrives the shimmer from left to right. - .clipShape constrains the shimmer within rounded rectangles.
Step 1: Build the SkeletonRow Component
Each placeholder is a gray rounded rectangle with a moving gradient overlay:
private struct SkeletonRow: View {
var width: CGFloat
var height: CGFloat
var isLoaded: Bool
var shimmerOffset: CGFloat
var body: some View {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.2))
.frame(maxWidth: width == .infinity ? .infinity : width,
maxHeight: height)
.frame(height: height)
.overlay {
if !isLoaded {
RoundedRectangle(cornerRadius: 8)
.fill(
LinearGradient(
colors: [.clear, .white.opacity(0.3), .clear],
startPoint: .leading,
endPoint: .trailing
)
)
.offset(x: shimmerOffset)
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
Step 2: Compose the Splash Layout
Arrange skeleton rows to mimic your real content structure. Here we simulate a header, three list items with avatars, and a large image placeholder:
struct SplashScreenDemo: View {
@State private var isLoaded = false
@State private var shimmerOffset: CGFloat = -200
var body: some View {
VStack(spacing: 20) {
SkeletonRow(width: 200, height: 30,
isLoaded: isLoaded, shimmerOffset: shimmerOffset)
ForEach(0..<3, id: \.self) { _ in
HStack(spacing: 12) {
SkeletonRow(width: 60, height: 60,
isLoaded: isLoaded, shimmerOffset: shimmerOffset)
VStack(alignment: .leading, spacing: 8) {
SkeletonRow(width: 180, height: 16,
isLoaded: isLoaded, shimmerOffset: shimmerOffset)
SkeletonRow(width: 120, height: 12,
isLoaded: isLoaded, shimmerOffset: shimmerOffset)
}
Spacer()
}
}
SkeletonRow(width: .infinity, height: 200,
isLoaded: isLoaded, shimmerOffset: shimmerOffset)
Spacer()
Button(isLoaded ? "Reset" : "Load Content") {
if isLoaded {
isLoaded = false
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation { isLoaded = true }
}
}
}
.buttonStyle(.borderedProminent)
}
.padding()
.onAppear {
withAnimation(.linear(duration: 1.5)
.repeatForever(autoreverses: false)) {
shimmerOffset = 400
}
}
}
}
How the Shimmer Works
The shimmer effect is a three-color LinearGradient (clear, white at 30% opacity, clear) that starts offset at -200 points to the left. On appear, a repeating linear animation moves it to +400 points, creating the left-to-right sweep. The .clipShape on each row ensures the gradient only appears within the placeholder bounds.
Tips and Pitfalls
- Match your real layout: The skeleton should mirror the dimensions of your actual content so there is no layout jump when data loads.
- Use .infinity for full-width: Pass
.infinityas the width for image placeholders that span the full screen. - Avoid autoreverses: The shimmer should only move in one direction. Using
autoreverses: falseprevents an unnatural back-and-forth sweep. - Transition to real content: Wrap your loaded content swap in
withAnimationto get a smooth crossfade rather than a jarring pop. - Accessibility: Consider reducing motion for users who enable Reduce Motion in system settings.
iOS Version: iOS 15+