```html SwiftUI: How to Shimmer Effect (iOS 17+, 2026)

How to implement a shimmer effect in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: LinearGradient / Animation Updated: May 12, 2026
TL;DR

Animate a LinearGradient across a view using withAnimation(.linear.repeatForever()) and clip it to the view's shape with .mask(). Wrap the logic in a ViewModifier so any view gains shimmer via .shimmer().

struct ShimmerModifier: ViewModifier {
    @State private var phase: CGFloat = -1

    func body(content: Content) -> some View {
        content
            .mask(
                GeometryReader { geo in
                    LinearGradient(
                        colors: [.clear, .white.opacity(0.8), .clear],
                        startPoint: UnitPoint(x: phase, y: 0.5),
                        endPoint:   UnitPoint(x: phase + 0.6, y: 0.5)
                    )
                    .frame(width: geo.size.width * 3)
                    .offset(x: -geo.size.width)
                }
            )
            .onAppear {
                withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
                    phase = 1
                }
            }
    }
}

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

Full implementation

The complete example builds a reusable skeleton card that mirrors the shape of a real content card — a common pattern for showing loading states before data arrives. The ShimmerModifier animates a highlight stripe across any view by positioning a wide LinearGradient relative to the view's own width, obtained via GeometryReader. A Boolean isLoading toggle in the preview lets you see the before/after states side by side.

import SwiftUI

// MARK: - Shimmer ViewModifier

struct ShimmerModifier: ViewModifier {
    @State private var phase: CGFloat = -1

    func body(content: Content) -> some View {
        content
            .mask(
                GeometryReader { geo in
                    LinearGradient(
                        colors: [
                            .clear,
                            .white.opacity(0.85),
                            .clear
                        ],
                        startPoint: UnitPoint(x: phase, y: 0.5),
                        endPoint:   UnitPoint(x: phase + 0.6, y: 0.5)
                    )
                    // Make the gradient wider than the view so it sweeps fully
                    .frame(width: geo.size.width * 2.5)
                    .offset(x: -geo.size.width * 0.5)
                }
            )
            .onAppear {
                withAnimation(
                    .linear(duration: 1.4)
                    .repeatForever(autoreverses: false)
                ) {
                    phase = 1.4
                }
            }
    }
}

extension View {
    /// Applies an infinite left-to-right shimmer highlight.
    func shimmer() -> some View {
        modifier(ShimmerModifier())
    }
}

// MARK: - Skeleton Card

struct SkeletonCard: View {
    var body: some View {
        HStack(spacing: 14) {
            // Avatar placeholder
            RoundedRectangle(cornerRadius: 12)
                .fill(Color.neutral200)
                .frame(width: 56, height: 56)
                .shimmer()

            VStack(alignment: .leading, spacing: 8) {
                // Title placeholder
                RoundedRectangle(cornerRadius: 6)
                    .fill(Color.neutral200)
                    .frame(height: 14)
                    .shimmer()

                // Subtitle placeholder (shorter)
                RoundedRectangle(cornerRadius: 6)
                    .fill(Color.neutral200)
                    .frame(width: 120, height: 12)
                    .shimmer()
            }
        }
        .padding()
        .background(Color.white)
        .clipShape(RoundedRectangle(cornerRadius: 16))
        .shadow(color: .black.opacity(0.06), radius: 8, y: 3)
    }
}

// MARK: - Demo View

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

    var body: some View {
        VStack(spacing: 20) {
            Toggle("Show loading state", isOn: $isLoading)
                .padding(.horizontal)

            if isLoading {
                // Show three skeleton rows
                ForEach(0..<3, id: \.self) { _ in
                    SkeletonCard()
                }
            } else {
                // Real content cards
                ForEach(["Swift 5.10 Released", "Xcode 16 Tips", "SwiftUI Deep Dive"], id: \.self) { title in
                    HStack(spacing: 14) {
                        RoundedRectangle(cornerRadius: 12)
                            .fill(Color.indigo.opacity(0.15))
                            .frame(width: 56, height: 56)
                        VStack(alignment: .leading, spacing: 4) {
                            Text(title).fontWeight(.semibold)
                            Text("soarias.com").font(.caption).foregroundStyle(.secondary)
                        }
                        Spacer()
                    }
                    .padding()
                    .background(Color.white)
                    .clipShape(RoundedRectangle(cornerRadius: 16))
                    .shadow(color: .black.opacity(0.06), radius: 8, y: 3)
                }
            }
        }
        .padding()
        .background(Color(.systemGroupedBackground))
        .animation(.easeInOut(duration: 0.3), value: isLoading)
    }
}

// Convenience color extension
private extension Color {
    static let neutral200 = Color(red: 0.9, green: 0.9, blue: 0.92)
}

// MARK: - Preview

#Preview {
    ShimmerDemoView()
}

How it works

  1. LinearGradient with three color stops — the gradient starts and ends with .clear and peaks at .white.opacity(0.85) in the middle. This creates the soft "gleam" stripe. Because it is applied as a mask, the white region lets the underlying view show through while the clear regions hide it, producing the illusion of a moving light.
  2. GeometryReader for proportional sizing — by reading geo.size.width, the gradient frame and offset scale to whatever view it wraps. A gradient 2.5× wider than the host view ensures the stripe enters and exits fully without clipping artefacts.
  3. phase state variable drives the sweepUnitPoint(x: phase, y: 0.5) maps the gradient start to a fraction of the view's width. Animating phase from -1 to 1.4 moves the stripe from left-offscreen to right-offscreen.
  4. .repeatForever(autoreverses: false) — the stripe always travels left-to-right (as users expect from shimmer UI), never bouncing back. The duration of 1.4 s matches the perceived speed of premium skeleton loaders in native iOS apps.
  5. Independent per-view animation — because @State private var phase is scoped inside the modifier, each shimmering view animates independently. Three SkeletonCard instances will not lock-step, which looks more natural.

Variants

Diagonal shimmer (45°)

For cards with a more stylized look, tilt the gradient by adjusting both x and y components of the UnitPoint. This is especially effective on large hero images.

LinearGradient(
    colors: [.clear, .white.opacity(0.85), .clear],
    startPoint: UnitPoint(x: phase,       y: phase),
    endPoint:   UnitPoint(x: phase + 0.5, y: phase + 0.5)
)
// Use the same withAnimation block — the 45° diagonal
// emerges naturally from equal x/y values.

Tinted shimmer for dark backgrounds

On dark-mode skeleton cards, swap the peak colour from .white.opacity(0.85) to .white.opacity(0.15) and use a darker fill for the placeholder rectangles (Color(white: 0.2)). The shimmer will read as a subtle brightening rather than a bright flash, which feels at home in dark UI.

You can also conditionally adjust the opacity with @Environment(\.colorScheme) inside the modifier to handle this automatically:

struct ShimmerModifier: ViewModifier {
    @State private var phase: CGFloat = -1
    @Environment(\.colorScheme) private var scheme

    private var peakOpacity: Double {
        scheme == .dark ? 0.18 : 0.85
    }

    func body(content: Content) -> some View {
        content
            .mask(
                GeometryReader { geo in
                    LinearGradient(
                        colors: [.clear, .white.opacity(peakOpacity), .clear],
                        startPoint: UnitPoint(x: phase, y: 0.5),
                        endPoint:   UnitPoint(x: phase + 0.6, y: 0.5)
                    )
                    .frame(width: geo.size.width * 2.5)
                    .offset(x: -geo.size.width * 0.5)
                }
            )
            .onAppear {
                withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
                    phase = 1.4
                }
            }
    }
}

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a shimmer effect in SwiftUI for iOS 17+.
Use LinearGradient and Animation (.linear.repeatForever).
Wrap it in a ViewModifier with a .shimmer() View extension.
Respect @Environment(\.accessibilityReduceMotion).
Make it accessible (VoiceOver labels like "Loading…" on skeleton views).
Add a #Preview with realistic sample data showing before/after states.

In Soarias, paste this into the Build phase prompt box — Claude Code will scaffold the modifier, skeleton layout, and accessibility guard in a single pass, ready to drop into your feature branch.

Related

FAQ

Does this work on iOS 16?

Yes — LinearGradient, ViewModifier, and .repeatForever(autoreverses:) are all available back to iOS 14. The only iOS 17-specific feature used in the demo is the #Preview macro; swap it for a PreviewProvider conformance if you need iOS 16 support. The shimmer animation itself is fully backward-compatible.

Can I shimmer text or images, not just rectangles?

Absolutely. Because the modifier uses .mask(), it works on any SwiftUI view — Text, Image, AsyncImage, or custom shapes. For images, apply .shimmer() before .resizable() so the mask respects the original frame. For Text, pair it with a .redacted(reason: .placeholder) call to also blur the letter shapes.

What is the UIKit equivalent?

In UIKit the standard approach is a CAGradientLayer added as a sublayer to the skeleton view, animated with a CABasicAnimation on the locations key path. You set repeatCount = .infinity on the animation. The SwiftUI LinearGradient + Animation approach achieves the same visual result with far less boilerplate and automatic layout integration.

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

```