How to Build a Skeleton Loader in SwiftUI
Overlay a traveling LinearGradient on RoundedRectangle placeholder shapes and animate the gradient's phase with .linear(duration:).repeatForever(autoreverses: false) to get a shimmer effect. Swap the skeleton for real content once your async load completes.
struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = -1
func body(content: Content) -> some View {
content
.overlay(
LinearGradient(
stops: [
.init(color: .clear, location: phase - 0.2),
.init(color: .white.opacity(0.45), location: phase),
.init(color: .clear, location: phase + 0.2),
],
startPoint: .leading,
endPoint: .trailing
)
.clipped()
)
.animation(
.linear(duration: 1.3).repeatForever(autoreverses: false),
value: phase
)
.onAppear { phase = 2 }
}
}
extension View {
func shimmer() -> some View { modifier(ShimmerModifier()) }
}
Full implementation
The pattern below wires everything together: a reusable ShimmerModifier, a SkeletonCardRow that mirrors a real content row's layout using RoundedRectangle shapes, and a ContentListView that uses .task to load data and flip the isLoading flag. Six skeleton rows render immediately while the network call runs, then dissolve into real content.
import SwiftUI
// MARK: - Shimmer ViewModifier
struct ShimmerModifier: ViewModifier {
@State private var phase: CGFloat = -1
func body(content: Content) -> some View {
content
.overlay(
LinearGradient(
stops: [
.init(color: .clear, location: phase - 0.2),
.init(color: .white.opacity(0.45), location: phase),
.init(color: .clear, location: phase + 0.2),
],
startPoint: .leading,
endPoint: .trailing
)
.clipped()
)
.animation(
.linear(duration: 1.3).repeatForever(autoreverses: false),
value: phase
)
.onAppear { phase = 2 }
}
}
extension View {
func shimmer() -> some View { modifier(ShimmerModifier()) }
}
// MARK: - Skeleton row matching a real content card
struct SkeletonCardRow: View {
var body: some View {
HStack(spacing: 12) {
// Avatar placeholder
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(width: 56, height: 56)
.shimmer()
VStack(alignment: .leading, spacing: 8) {
// Title line placeholder
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
.frame(height: 14)
.shimmer()
// Subtitle line placeholder (shorter)
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
.frame(width: 130, height: 12)
.shimmer()
}
Spacer()
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
// Tell VoiceOver this entire row is a loading placeholder
.accessibilityLabel("Loading content")
.accessibilityAddTraits(.updatesFrequently)
}
}
// MARK: - Real article row
struct ArticleRow: View {
let title: String
let subtitle: String
var body: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 8)
.fill(Color.accentColor.opacity(0.15))
.frame(width: 56, height: 56)
.overlay(Image(systemName: "doc.text").foregroundStyle(.accent))
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
Text(subtitle).font(.subheadline).foregroundStyle(.secondary)
}
Spacer()
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.06), radius: 6, y: 2)
}
}
// MARK: - Main view
struct ArticleListView: View {
@State private var isLoading = true
@State private var articles: [(title: String, subtitle: String)] = []
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 12) {
if isLoading {
ForEach(0..<6, id: \.self) { _ in
SkeletonCardRow()
.padding(.horizontal)
}
} else {
ForEach(articles, id: \.title) { article in
ArticleRow(title: article.title,
subtitle: article.subtitle)
.padding(.horizontal)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
}
.padding(.vertical)
.animation(.easeOut(duration: 0.35), value: isLoading)
}
.navigationTitle("Articles")
.task {
// Simulate a 2-second network fetch
try? await Task.sleep(for: .seconds(2))
articles = [
("SwiftUI Basics", "5 min read"),
("Combine in Practice", "8 min read"),
("Async/Await Guide", "6 min read"),
("Swift Macros 101", "10 min read"),
("SwiftData Deep Dive", "12 min read"),
]
withAnimation { isLoading = false }
}
}
}
}
#Preview {
ArticleListView()
}
How it works
-
ShimmerModifier phase animation. The
@State var phasestarts at-1(the gradient highlight is fully off-screen left) and jumps to2in.onAppear, which is off-screen right. The.linear(duration: 1.3).repeatForever(autoreverses: false)modifier drives an infinite left-to-right sweep, creating the shimmer illusion. -
RoundedRectangle as placeholder shapes. Each skeleton element is a
RoundedRectanglefilled withColor(.systemGray5)— a system color that automatically adapts to Dark Mode. Sizing matches the real content (56×56 avatar, 14 pt title, 12 pt subtitle) so the layout doesn't jump when data arrives. -
.clipped() prevents overflow. The
LinearGradientoverlay is wider than the view by design (phase runs from −1 to 2). Calling.clipped()on the overlay keeps the sweep inside theRoundedRectanglebounds so it doesn't bleed into neighboring views. -
.task for async data loading. SwiftUI's
.taskmodifier runs anasyncclosure tied to the view's lifetime and cancels automatically if the view disappears — no manualonDisappearcleanup required. After the simulated delay,isLoadingflips tofalse. -
Animated transition to real content. Wrapping the
isLoading = falseassignment inwithAnimationand adding.transition(.opacity.combined(with: .move(edge: .bottom)))to the real rows produces a smooth fade-slide-in instead of a jarring snap.
Variants
Pulse-only (no shimmer overlay)
If you want a simpler effect — just a breathing opacity — skip the LinearGradient overlay entirely and drive opacity with .easeInOut.repeatForever(autoreverses: true) directly on the RoundedRectangle.
struct PulseModifier: ViewModifier {
@State private var dim = false
func body(content: Content) -> some View {
content
.opacity(dim ? 0.35 : 0.7)
.animation(
.easeInOut(duration: 0.85).repeatForever(autoreverses: true),
value: dim
)
.onAppear { dim = true }
}
}
extension View {
func pulse() -> some View { modifier(PulseModifier()) }
}
// Usage — drop in anywhere instead of .shimmer()
RoundedRectangle(cornerRadius: 8)
.fill(Color(.systemGray5))
.frame(height: 14)
.pulse()
Using SwiftUI's built-in .redacted(reason: .placeholder)
SwiftUI ships a first-party placeholder system: apply .redacted(reason: .placeholder) to your real view while data is loading, and SwiftUI renders muted rounded blobs where text and images sit. It's zero-boilerplate and fully accessible, but you get no shimmer and less control over shape sizes. Combine it with .unredacted() on interactive elements (buttons, toggles) that should stay live during loading.
Common pitfalls
-
Forgetting
.clipped()on the shimmer overlay. Without it theLinearGradientsweep bleeds across card edges and neighbouring rows. Always clip the overlay to the content's bounds. - Hardcoding placeholder sizes that don't match real content. If your skeleton avatar is 56×56 but the real image is 48×48, the layout will visibly shift when content loads. Measure your real views first and match dimensions exactly in the skeleton.
-
Missing VoiceOver labels on skeleton rows. Screen-reader users hear nothing useful if you skip
.accessibilityLabel("Loading content"). Add it and.accessibilityAddTraits(.updatesFrequently)so VoiceOver announces updates when real content replaces the placeholder. -
Animating inside a
ListvsLazyVStack.Listrecycles cells aggressively — skeleton rows may not animate smoothly because cell reuse interruptsonAppear. PreferLazyVStackinside aScrollViewfor shimmer-heavy screens, as shown in the full example.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a skeleton loader in SwiftUI for iOS 17+. Use RoundedRectangle and a LinearGradient shimmer Animation. Mirror the layout of a real ArticleRow (56x56 avatar, title, subtitle). Make it accessible (VoiceOver labels, .updatesFrequently trait). Toggle from skeleton to real content after an async fetch. Add a #Preview with realistic sample data.
In the Soarias Build phase, paste this prompt into the active Claude Code session after your screen mockups are locked — Claude will scaffold the shimmer modifier, placeholder rows, and loading state toggle in one pass, leaving you to wire in your real data source.
Related
FAQ
-
Does this work on iOS 16?
TheShimmerModifieritself is compatible with iOS 15+. However, the.taskmodifier (used for async loading) requires iOS 15+, and the#Previewmacro requires Xcode 15 targeting iOS 17+. If you need iOS 16 support, replace#Previewwith aPreviewProviderand replacetry? await Task.sleep(for: .seconds(2))withTask.sleep(nanoseconds: 2_000_000_000). -
Can I reuse the shimmer modifier for images and text, not just placeholders?
Yes —.shimmer()is a genericViewModifieryou can apply to any view, including realAsyncImagewhile it's loading or anyTextacting as a placeholder. Just be mindful that theLinearGradientoverlay is white-tinted: on dark backgrounds, swap the highlight color to.black.opacity(0.2)for a darkening sweep instead. -
What's the UIKit equivalent?
In UIKit you'd typically use aCAGradientLayeradded as a sublayer to aUIView, then animate itslocationsproperty with aCABasicAnimationset to repeat indefinitely. SwiftUI's approach is significantly less code and auto-handles layout changes, makingShimmerModifierthe preferred path for any new SwiftUI project.
Last reviewed: 2026-05-11 by the Soarias team.