```html SwiftUI: How to Build a Skeleton Loader (iOS 17+, 2026)

How to Build a Skeleton Loader in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: RoundedRectangle, Animation Updated: May 11, 2026
TL;DR

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

  1. ShimmerModifier phase animation. The @State var phase starts at -1 (the gradient highlight is fully off-screen left) and jumps to 2 in .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.
  2. RoundedRectangle as placeholder shapes. Each skeleton element is a RoundedRectangle filled with Color(.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.
  3. .clipped() prevents overflow. The LinearGradient overlay is wider than the view by design (phase runs from −1 to 2). Calling .clipped() on the overlay keeps the sweep inside the RoundedRectangle bounds so it doesn't bleed into neighboring views.
  4. .task for async data loading. SwiftUI's .task modifier runs an async closure tied to the view's lifetime and cancels automatically if the view disappears — no manual onDisappear cleanup required. After the simulated delay, isLoading flips to false.
  5. Animated transition to real content. Wrapping the isLoading = false assignment in withAnimation and 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

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

Last reviewed: 2026-05-11 by the Soarias team.

```