How to implement a shimmer effect in SwiftUI
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
-
LinearGradientwith three color stops — the gradient starts and ends with.clearand 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. -
GeometryReaderfor proportional sizing — by readinggeo.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. -
phasestate variable drives the sweep —UnitPoint(x: phase, y: 0.5)maps the gradient start to a fraction of the view's width. Animatingphasefrom-1to1.4moves the stripe from left-offscreen to right-offscreen. -
.repeatForever(autoreverses: false)— the stripe always travels left-to-right (as users expect from shimmer UI), never bouncing back. The duration of1.4 smatches the perceived speed of premium skeleton loaders in native iOS apps. -
Independent per-view animation — because
@State private var phaseis scoped inside the modifier, each shimmering view animates independently. ThreeSkeletonCardinstances 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
-
Calling
withAnimationbeforeonAppearfires — starting the animation ininitor as a default@Statevalue means SwiftUI may not yet have a render loop attached. Always trigger inside.onAppear(or.taskon iOS 15+). -
Forgetting to size the gradient wider than the view — if the
LinearGradientframe exactly matches the masked view, the stripe appears to jump rather than sweep. Make the gradient at least 2× the view width and offset it so the stripe has room to enter and exit smoothly. -
Accessibility — motion sensitivity — users who enable Reduce Motion
in iOS Settings find pulsing/looping animations distracting or nauseating. Guard the animation
with
@Environment(\.accessibilityReduceMotion)and fall back to a static low-opacity placeholder when it istrue.
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.