- Published on
Creating Skeleton Loading Views (Shimmer Effect) in SwiftUI
- Authors

- Name
- Mick MacCallum
- @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
LazyVStackandLazyHStackfor 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.
Continue Learning
Prevent Drag-to-Dismiss on SwiftUI Sheets
Learn how to prevent users from dismissing modal sheets by swiping down in SwiftUI using interactiveDismissDisabled.
Building a Floating Action Button (FAB) that Respects Keyboard in SwiftUI
Learn how to create a Material Design-style floating action button in SwiftUI that intelligently moves above the keyboard and avoids blocking text input fields.
Creating an iMessage-Style Input Accessory View in SwiftUI
Learn how to create a custom input accessory view that stays above the keyboard, similar to iMessage's input bar.
