avatar
Published on

Creating Skeleton Loading Views (Shimmer Effect) in SwiftUI

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @0x7fs

Skeleton screens (also called placeholder loading or ghost elements) show users a preview of content structure while data loads. Combined with a shimmer animation, they create a polished, modern loading experience that feels faster than spinners and keeps users engaged.

Apps like Facebook, LinkedIn, and YouTube use skeleton screens extensively. SwiftUI makes it easy to build these with gradients and custom view modifiers.

Why Skeleton Screens?

Compared to loading spinners, skeleton screens:

  • Give users a sense of what's coming
  • Feel faster (perceived performance boost)
  • Reduce uncertainty and anxiety during loading
  • Look more polished and modern
  • Keep users engaged instead of waiting passively

Building the Shimmer Effect

The shimmer is a moving gradient that creates an animated shine across the skeleton:

import SwiftUI

struct Shimmer: ViewModifier {
    @State private var phase: CGFloat = 0

    func body(content: Content) -> some View {
        content
            .overlay(
                GeometryReader { geometry in
                    let gradientWidth = geometry.size.width * 0.4

                    LinearGradient(
                        gradient: Gradient(colors: [
                            .clear,
                            Color.white.opacity(0.6),
                            .clear
                        ]),
                        startPoint: .leading,
                        endPoint: .trailing
                    )
                    .frame(width: gradientWidth)
                    .offset(x: phase * (geometry.size.width + gradientWidth) - gradientWidth)
                }
            )
            .onAppear {
                withAnimation(
                    .linear(duration: 1.5)
                    .repeatForever(autoreverses: false)
                ) {
                    phase = 1
                }
            }
    }
}

extension View {
    func shimmer() -> some View {
        modifier(Shimmer())
    }
}

Creating Skeleton Shapes

Build basic skeleton building blocks:

struct SkeletonView: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Image placeholder
            Rectangle()
                .fill(Color.gray.opacity(0.3))
                .frame(height: 200)
                .cornerRadius(12)

            // Title placeholder
            Rectangle()
                .fill(Color.gray.opacity(0.3))
                .frame(height: 24)
                .frame(maxWidth: .infinity)
                .cornerRadius(4)

            // Subtitle placeholder
            Rectangle()
                .fill(Color.gray.opacity(0.3))
                .frame(height: 20)
                .frame(width: 200)
                .cornerRadius(4)

            // Description placeholders
            VStack(alignment: .leading, spacing: 8) {
                Rectangle()
                    .fill(Color.gray.opacity(0.3))
                    .frame(height: 16)
                    .cornerRadius(4)

                Rectangle()
                    .fill(Color.gray.opacity(0.3))
                    .frame(height: 16)
                    .frame(width: 250)
                    .cornerRadius(4)
            }
        }
        .padding()
        .shimmer()
    }
}

Reusable Skeleton Components

Create reusable skeleton shapes:

struct SkeletonRectangle: View {
    let height: CGFloat
    let width: CGFloat?
    let cornerRadius: CGFloat

    init(height: CGFloat, width: CGFloat? = nil, cornerRadius: CGFloat = 4) {
        self.height = height
        self.width = width
        self.cornerRadius = cornerRadius
    }

    var body: some View {
        Rectangle()
            .fill(Color.gray.opacity(0.3))
            .frame(width: width, height: height)
            .frame(maxWidth: width == nil ? .infinity : nil)
            .cornerRadius(cornerRadius)
    }
}

struct SkeletonCircle: View {
    let size: CGFloat

    var body: some View {
        Circle()
            .fill(Color.gray.opacity(0.3))
            .frame(width: size, height: size)
    }
}

struct SkeletonText: View {
    let width: CGFloat?
    let lines: Int

    init(width: CGFloat? = nil, lines: Int = 1) {
        self.width = width
        self.lines = lines
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            ForEach(0..<lines, id: \.self) { index in
                SkeletonRectangle(
                    height: 16,
                    width: index == lines - 1 ? (width ?? 200) * 0.7 : width
                )
            }
        }
    }
}

Card Skeleton Example

Build a complete card skeleton:

struct CardSkeleton: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            // Header with avatar and name
            HStack {
                SkeletonCircle(size: 40)

                VStack(alignment: .leading, spacing: 4) {
                    SkeletonRectangle(height: 16, width: 120)
                    SkeletonRectangle(height: 12, width: 80)
                }

                Spacer()
            }

            // Image
            SkeletonRectangle(height: 200, cornerRadius: 12)

            // Text content
            SkeletonText(lines: 3)

            // Footer with buttons
            HStack {
                SkeletonRectangle(height: 32, width: 80, cornerRadius: 16)
                SkeletonRectangle(height: 32, width: 80, cornerRadius: 16)
                Spacer()
            }
        }
        .padding()
        .background(Color.white)
        .cornerRadius(16)
        .shadow(radius: 2)
        .shimmer()
    }
}

List Skeleton

Create a loading list:

struct ListSkeleton: View {
    let rows: Int

    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                ForEach(0..<rows, id: \.self) { _ in
                    HStack(spacing: 12) {
                        SkeletonCircle(size: 50)

                        VStack(alignment: .leading, spacing: 8) {
                            SkeletonRectangle(height: 18, width: 200)
                            SkeletonRectangle(height: 14, width: 150)
                        }

                        Spacer()
                    }
                    .padding()
                    .background(Color.white)
                    .cornerRadius(12)
                }
            }
            .padding()
        }
        .shimmer()
    }
}

Conditional Loading View

Show skeleton while loading, then real content:

struct ContentView: View {
    @State private var isLoading = true
    @State private var items: [Item] = []

    var body: some View {
        Group {
            if isLoading {
                ListSkeleton(rows: 5)
            } else {
                List(items) { item in
                    ItemRow(item: item)
                }
            }
        }
        .task {
            await loadData()
        }
    }

    func loadData() async {
        try? await Task.sleep(nanoseconds: 3_000_000_000)
        items = Item.sampleData
        isLoading = false
    }
}

struct Item: Identifiable {
    let id = UUID()
    let title: String
    let subtitle: String

    static let sampleData = [
        Item(title: "Item 1", subtitle: "Description 1"),
        Item(title: "Item 2", subtitle: "Description 2"),
        Item(title: "Item 3", subtitle: "Description 3"),
    ]
}

Advanced Shimmer with AnimatableModifier

For more control, use AnimatableModifier:

struct ShimmerModifier: AnimatableModifier {
    var phase: CGFloat

    var animatableData: CGFloat {
        get { phase }
        set { phase = newValue }
    }

    func body(content: Content) -> some View {
        content
            .overlay(
                GeometryReader { geometry in
                    let gradientWidth = geometry.size.width * 0.3

                    LinearGradient(
                        gradient: Gradient(stops: [
                            .init(color: .clear, location: 0),
                            .init(color: Color.white.opacity(0.5), location: 0.5),
                            .init(color: .clear, location: 1)
                        ]),
                        startPoint: .leading,
                        endPoint: .trailing
                    )
                    .frame(width: gradientWidth)
                    .offset(x: phase * (geometry.size.width + gradientWidth) - gradientWidth)
                }
                .mask(content)
            )
    }
}

extension View {
    func advancedShimmer(active: Bool = true, duration: Double = 1.5) -> some View {
        modifier(ShimmerModifier(phase: active ? 1 : 0))
            .onAppear {
                guard active else { return }
                withAnimation(
                    .linear(duration: duration)
                    .repeatForever(autoreverses: false)
                ) {
                    // Animation handled by modifier
                }
            }
    }
}

Grid Skeleton

Skeleton for grid layouts:

struct GridSkeleton: View {
    let columns = [
        GridItem(.flexible()),
        GridItem(.flexible())
    ]

    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 16) {
                ForEach(0..<6, id: \.self) { _ in
                    VStack(alignment: .leading, spacing: 8) {
                        SkeletonRectangle(height: 150, cornerRadius: 12)

                        SkeletonRectangle(height: 16, width: 100)

                        SkeletonRectangle(height: 14, width: 80)
                    }
                }
            }
            .padding()
        }
        .shimmer()
    }
}

Profile Skeleton

Complex skeleton for profile screens:

struct ProfileSkeleton: View {
    var body: some View {
        VStack(spacing: 20) {
            // Cover photo
            SkeletonRectangle(height: 200)

            // Profile picture (overlapping)
            SkeletonCircle(size: 100)
                .offset(y: -50)
                .padding(.bottom, -50)

            // Name and bio
            VStack(spacing: 8) {
                SkeletonRectangle(height: 24, width: 200)
                SkeletonRectangle(height: 16, width: 150)
            }

            // Stats
            HStack(spacing: 30) {
                ForEach(0..<3, id: \.self) { _ in
                    VStack(spacing: 4) {
                        SkeletonRectangle(height: 20, width: 50)
                        SkeletonRectangle(height: 14, width: 60)
                    }
                }
            }
            .padding(.vertical)

            // Action buttons
            HStack(spacing: 12) {
                SkeletonRectangle(height: 44, cornerRadius: 22)
                SkeletonRectangle(height: 44, width: 100, cornerRadius: 22)
            }
            .padding(.horizontal)

            // Bio text
            VStack(alignment: .leading, spacing: 8) {
                SkeletonText(lines: 4)
            }
            .padding(.horizontal)

            Spacer()
        }
        .shimmer()
    }
}

Customizing Shimmer Colors

Match your app's theme:

struct ThemedShimmer: ViewModifier {
    @State private var phase: CGFloat = 0
    let baseColor: Color
    let highlightColor: Color

    init(baseColor: Color = Color.gray.opacity(0.3),
         highlightColor: Color = Color.white.opacity(0.6)) {
        self.baseColor = baseColor
        self.highlightColor = highlightColor
    }

    func body(content: Content) -> some View {
        content
            .overlay(
                GeometryReader { geometry in
                    let gradientWidth = geometry.size.width * 0.4

                    LinearGradient(
                        gradient: Gradient(colors: [
                            .clear,
                            highlightColor,
                            .clear
                        ]),
                        startPoint: .leading,
                        endPoint: .trailing
                    )
                    .frame(width: gradientWidth)
                    .offset(x: phase * (geometry.size.width + gradientWidth) - gradientWidth)
                }
            )
            .onAppear {
                withAnimation(
                    .linear(duration: 1.5)
                    .repeatForever(autoreverses: false)
                ) {
                    phase = 1
                }
            }
    }
}

// Usage
SkeletonView()
    .modifier(ThemedShimmer(
        baseColor: Color.blue.opacity(0.2),
        highlightColor: Color.blue.opacity(0.4)
    ))

Performance Considerations

Optimize for performance:

  • Apply .shimmer() to parent container, not each skeleton element
  • Use LazyVStack and LazyHStack for long lists
  • Limit number of skeleton rows (5-10 is usually enough)
  • Consider reducing animation complexity on older devices
struct OptimizedListSkeleton: View {
    var body: some View {
        ScrollView {
            LazyVStack(spacing: 16) {
                ForEach(0..<10, id: \.self) { _ in
                    SkeletonRow()
                }
            }
            .padding()
        }
        .shimmer() // Single shimmer for entire list
    }
}

Transitioning from Skeleton to Content

Smooth transition with fade animation:

struct FadeTransitionView: View {
    @State private var isLoading = true

    var body: some View {
        ZStack {
            if isLoading {
                CardSkeleton()
                    .transition(.opacity)
            } else {
                ActualCard()
                    .transition(.opacity)
            }
        }
        .animation(.easeInOut(duration: 0.3), value: isLoading)
        .task {
            try? await Task.sleep(nanoseconds: 2_000_000_000)
            isLoading = false
        }
    }
}

Best Practices

Do:

  • Match skeleton shapes to actual content layout
  • Use subtle, fast animations (1-2 seconds)
  • Apply shimmer to container, not individual elements
  • Keep skeleton screens simple
  • Test on actual devices for performance

Don't:

  • Make skeletons too detailed (defeats the purpose)
  • Use slow animations (> 2 seconds)
  • Show skeletons for instant loads (< 300ms)
  • Create skeletons for every possible state
  • Forget to handle errors (show error state, not skeleton)

When to Use Skeleton Screens

Good for:

  • Initial page load
  • Infinite scroll loading
  • Pull-to-refresh
  • Tab switching with network requests
  • List and grid views

Not good for:

  • Form submissions (use button loading state)
  • Very fast operations (< 300ms)
  • Error states (use dedicated error view)
  • Progressive actions (use progress bars)

Skeleton screens with shimmer effects make your SwiftUI app feel responsive and polished, keeping users engaged during load times.