A layout-aware, auto-generating skeleton loader for SwiftUI.
- iOS 16+
- Swift 5.9+
- SwiftUI
Add with Swift Package Manager:
https://github.com/Sharnabh/ShimmerKit
In Xcode:
- File → Add Packages
- Paste the URL
- Add
ShimmerKit
import SwiftUI
import ShimmerKit
struct ProductView: View {
@State private var isLoading = true
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Product title")
.skeletonNode(kind: .text(lineHeight: 18))
Text("Product subtitle")
.skeletonNode(kind: .text(lineHeight: 14))
RoundedRectangle(cornerRadius: 12)
.frame(height: 120)
.skeletonNode(kind: .image)
}
.smartSkeleton(isLoading)
}
}For a single app that demonstrates every public API and feature toggle, see:
Examples/ShimmerKitShowcase/
- Added whole-view loading overloads with a separate non-shimmering background layer.
- Improved whole-view loading lifecycle so original content remains mounted (hidden) during loading, avoiding unintended
.taskcancellation. - Improved loading overlay alignment to top-leading for predictable top anchoring.
func smartSkeleton(
_ isLoading: Bool,
config: ShimmerConfig = ShimmerConfig(),
includeScopes: [String]? = nil
) -> some ViewisLoading: whentrue, skeletons render.config: shimmer behavior and style.includeScopes: optional partial rendering filter.
Examples:
content.smartSkeleton(isLoading)
content.smartSkeleton(
isLoading,
config: ShimmerKit.config(.feedLoading),
includeScopes: ["header", "body"]
)func shimmerLoading<Placeholder: View>(
_ isLoading: Bool,
config: ShimmerConfig = ShimmerConfig(),
@ViewBuilder placeholder: () -> Placeholder
) -> some View- Replaces the entire original content while loading.
- Shows only your custom placeholder (text, shapes, or any view) with shimmer applied.
Example:
content.shimmerLoading(isLoading, config: ShimmerKit.config(.feedLoading)) {
VStack(alignment: .leading, spacing: 10) {
Text("Loading feed")
RoundedRectangle(cornerRadius: 8).frame(height: 54)
RoundedRectangle(cornerRadius: 8).frame(height: 54)
}
}func shimmerLoading<Placeholder: View>(
_ controller: ShimmerLoadingController,
config: ShimmerConfig = ShimmerConfig(),
@ViewBuilder placeholder: () -> Placeholder
) -> some View- Use this when loading state is shared across screens.
- Useful for showing loading in a home/root container while work runs in child views.
func shimmerLoading<Background: View, Placeholder: View>(
_ isLoading: Bool,
config: ShimmerConfig = ShimmerConfig(),
@ViewBuilder background: () -> Background,
@ViewBuilder placeholder: () -> Placeholder
) -> some View- Renders a non-shimmering background while loading.
- Applies shimmer only to the placeholder layer.
Example:
content.shimmerLoading(
isLoading,
config: ShimmerKit.config(.feedLoading),
background: {
Color("LoadingBackground")
},
placeholder: {
VStack(spacing: 12) {
RoundedRectangle(cornerRadius: 10).frame(height: 40)
RoundedRectangle(cornerRadius: 10).frame(height: 140)
}
}
)func shimmerLoading<Background: View, Placeholder: View>(
_ controller: ShimmerLoadingController,
config: ShimmerConfig = ShimmerConfig(),
@ViewBuilder background: () -> Background,
@ViewBuilder placeholder: () -> Placeholder
) -> some View- Same behavior as above, but tied to shared loading controller state.
func shimmerText(
config: ShimmerConfig = ShimmerConfig(),
baseColor: Color = .primary
) -> some View- Applies animated shimmer directly through a single text view.
- Independent of
smartSkeletonloading flow.
Example:
Text("Hello")
.font(.largeTitle.weight(.bold))
.shimmerText(
config: ShimmerConfig(
gradient: Gradient(colors: [.clear, .pink.opacity(0.9), .orange.opacity(0.9), .clear]),
speed: 1.0,
angle: .degrees(20)
),
baseColor: .gray.opacity(0.35)
)func shimmerTextSweep(
config: ShimmerConfig = ShimmerConfig(),
baseColor: Color = .primary
) -> some View- Applies one aligned sweep across all text inside a parent container.
Example:
VStack(alignment: .leading) {
Text("Headline")
Text("Subtitle")
}
.shimmerTextSweep(config: ShimmerKit.config(.subtle), baseColor: .gray.opacity(0.3))func shimmerTextSweepExclude(_ isExcluded: Bool = true) -> some View- Excludes a specific text view or nested stack from the parent
shimmerTextSweepeffect.
Example:
VStack(alignment: .leading) {
Text("Swept text")
Text("No sweep here")
.shimmerTextSweepExclude()
}
.shimmerTextSweep(config: ShimmerKit.config(.subtle))func skeletonNode(
cornerRadius: CGFloat? = nil,
kind: SkeletonKind? = nil,
shape: SkeletonShapeStyle = .automatic,
scope: String? = nil
) -> some View- Marks a view as a skeleton target.
scopeworks withincludeScopesinsmartSkeleton.
Examples:
Text("Title")
.skeletonNode(kind: .text(lineHeight: 18), shape: .capsule, scope: "header")
Circle()
.frame(width: 44, height: 44)
.skeletonNode(kind: .image, shape: .circle, scope: "avatar")func skeletonID(_ id: AnyHashable) -> some View- Provides stable identity for lazy containers/lists.
Example:
ForEach(items, id: \.id) { item in
Row(item: item)
.skeletonID(item.id)
}@MainActor
public final class ShimmerLoadingController: ObservableObjectPurpose:
- Tracks concurrent async operations.
- Keeps
isLoadingtrue until the last operation finishes.
Key APIs:
public func beginLoading()
public func endLoading()
public func run<T>(_ operation: @Sendable () async throws -> T) async rethrows -> T
public func runTaskGroup<ChildTaskResult: Sendable, GroupResult>(
of childTaskResultType: ChildTaskResult.Type,
returning returnType: GroupResult.Type,
body: (inout TaskGroup<ChildTaskResult>) async -> GroupResult
) async -> GroupResult
public func runThrowingTaskGroup<ChildTaskResult: Sendable, GroupResult>(
of childTaskResultType: ChildTaskResult.Type,
returning returnType: GroupResult.Type,
body: (inout ThrowingTaskGroup<ChildTaskResult, any Error>) async throws -> GroupResult
) async throws -> GroupResultRoot-level loading example:
@StateObject private var loadingController = ShimmerLoadingController()
NavigationStack {
HomeView()
}
.shimmerLoading(loadingController, config: ShimmerKit.config(.detailPage)) {
Text("Preparing your content")
}Child-view work example with multiple calls in one task:
Task {
let payload = try await loadingController.run {
let profile = try await api.loadProfile()
let permissions = try await api.loadPermissions()
let feed = try await api.loadFeed()
return (profile, permissions, feed)
}
// Update UI
}Child-view task-group example:
Task {
let values = await loadingController.runTaskGroup(of: String.self, returning: [String].self) { group in
group.addTask { await api.loadSectionA() }
group.addTask { await api.loadSectionB() }
group.addTask { await api.loadSectionC() }
var output: [String] = []
for await value in group { output.append(value) }
return output
}
// Update UI
}public static let defaultConfig: ShimmerConfigExample:
content.smartSkeleton(isLoading, config: ShimmerKit.defaultConfig)public static func config(_ profile: ShimmerProfile) -> ShimmerConfigAvailable profiles:
.default.subtle.feedLoading.detailPage
Example:
content.smartSkeleton(isLoading, config: ShimmerKit.config(.subtle))public static func config(
gradient: Gradient = Gradient(colors: [.clear, Color.white.opacity(0.35), .clear]),
textGradient: Gradient? = nil,
skeletonColor: Color = Color.gray.opacity(0.25),
speed: Double = 1.2,
angle: Angle = .degrees(20),
splitMultilineText: Bool = false,
enableSemanticGrouping: Bool = false,
useLayoutProtocolIntegration: Bool = false
) -> ShimmerConfigExample:
let config = ShimmerKit.config(
gradient: Gradient(colors: [.clear, .purple.opacity(0.4), .clear]),
skeletonColor: .purple.opacity(0.15),
speed: 1.0,
angle: .degrees(35),
splitMultilineText: true,
enableSemanticGrouping: true,
useLayoutProtocolIntegration: false
)public static func config(
shimmerColor: Color,
textGradient: Gradient? = nil,
skeletonColor: Color = Color.gray.opacity(0.25),
shimmerOpacity: Double = 0.35,
speed: Double = 1.2,
angle: Angle = .degrees(20),
splitMultilineText: Bool = false,
enableSemanticGrouping: Bool = false,
useLayoutProtocolIntegration: Bool = false
) -> ShimmerConfigExample:
let config = ShimmerKit.config(
shimmerColor: .mint,
skeletonColor: .mint.opacity(0.18),
shimmerOpacity: 0.45,
speed: 1.0,
angle: .degrees(30),
splitMultilineText: true,
enableSemanticGrouping: true,
useLayoutProtocolIntegration: true
)ShimmerConfig is the central style/behavior object.
gradient: GradienttextGradient: Gradient?skeletonColor: Colorspeed: Doubleangle: AnglesplitMultilineText: BoolenableSemanticGrouping: BooluseLayoutProtocolIntegration: Bool
public init(
gradient: Gradient = Gradient(colors: [.clear, Color.white.opacity(0.35), .clear]),
textGradient: Gradient? = nil,
skeletonColor: Color = Color.gray.opacity(0.25),
speed: Double = 1.2,
angle: Angle = .degrees(20),
splitMultilineText: Bool = false,
enableSemanticGrouping: Bool = false,
useLayoutProtocolIntegration: Bool = false
)public init(
shimmerColor: Color,
textGradient: Gradient? = nil,
skeletonColor: Color = Color.gray.opacity(0.25),
shimmerOpacity: Double = 0.35,
speed: Double = 1.2,
angle: Angle = .degrees(20),
splitMultilineText: Bool = false,
enableSemanticGrouping: Bool = false,
useLayoutProtocolIntegration: Bool = false
)public enum ShimmerProfile: Hashable, Sendable {
case `default`
case subtle
case feedLoading
case detailPage
}public enum SkeletonKind: Hashable, Sendable {
case text(lineHeight: CGFloat)
case image
case generic
}public enum SkeletonShapeStyle: Hashable, Sendable {
case automatic
case roundedRectangle(cornerRadius: CGFloat)
case capsule
case circle
}SkeletonNode is the captured/rendered node model used internally and exposed publicly.
public struct SkeletonNode: Identifiable, Hashable, Sendable {
public var id: String { get }
public var frame: CGRect
public var cornerRadius: CGFloat
public var kind: SkeletonKind
public var shapeStyle: SkeletonShapeStyle
public var scope: String?
}All advanced behavior is opt-in and defaults to off:
splitMultilineTextenableSemanticGroupinguseLayoutProtocolIntegration
Example:
let config = ShimmerConfig(
shimmerColor: .cyan,
splitMultilineText: true,
enableSemanticGrouping: true,
useLayoutProtocolIntegration: true
)
content.smartSkeleton(isLoading, config: config)VStack {
Text("Header").skeletonNode(scope: "header")
Text("Body").skeletonNode(scope: "body")
Button("Retry") {}.skeletonNode(scope: "actions")
}
.smartSkeleton(
true,
includeScopes: ["header", "body"]
)MIT
- Release process:
RELEASE_CHECKLIST.md
A layout-aware, auto-generating skeleton loader for SwiftUI.
Not another shimmer modifier. This is a rendering system that mirrors your actual UI layout and builds skeletons automatically.
-
⚡ Auto Layout Skeletons No manual placeholder views. It reads your real UI and generates skeletons.
-
🧠 Heuristic-Based Rendering Detects:
- Text → pill-shaped lines
- Images → rounded blocks
- Generic views → adaptive shapes
-
🎯 Zero Layout Duplication Your skeleton always matches your UI. No maintenance hell.
-
🔄 Timeline-based Animation Uses
TimelineViewfor smooth, frame-synced shimmer. -
📦 Swift Package Manager Ready
-
🧵 Swift 6 Concurrency Safe
-
📱 iOS 16+ Only (by design)
Add this to your project:
https://github.com/Sharnabh/ShimmerKit
Or in Xcode:
- File → Add Packages
- Paste the repo URL
- Select ShimmerKit
ProductCards(...)
.smartSkeleton(true)That’s it.
No duplicate UI. No placeholder views.
.smartSkeleton(isLoading)true→ skeleton shownfalse→ real UI shown
-
Your views are rendered normally (but hidden)
-
Layout frames are captured using
GeometryReader -
Frames are processed:
- filtered
- merged
- grouped
-
Skeleton shapes are drawn on top
-
Shimmer animation is applied via
TimelineView
Override automatic detection when needed:
Text("Title")
.skeletonNode(kind: .text(lineHeight: 12))
AsyncImage(...)
.skeletonNode(kind: .image, cornerRadius: 12)ForEach(items, id: \.id) { item in
ProductCards(...)
.skeletonID(item.id)
}Prevents flickering and incorrect frame reuse.
.smartSkeleton(
true,
config: ShimmerConfig(
gradient: Gradient(colors: [
.clear,
.white.opacity(0.4),
.clear
]),
skeletonColor: Color.gray.opacity(0.2),
speed: 0.8,
angle: .degrees(45)
)
)Use a single shimmer tint color and custom base skeleton color:
.smartSkeleton(
true,
config: ShimmerKit.config(
shimmerColor: .mint,
skeletonColor: .mint.opacity(0.18),
shimmerOpacity: 0.45,
speed: 1.0,
angle: .degrees(30)
)
)struct ProductView: View {
@State private var isLoading = true
var body: some View {
VStack {
Text("Product Title").skeletonNode()
Text("₹99").skeletonNode()
}
.smartSkeleton(isLoading)
}
}VStack(alignment: .leading, spacing: 8) {
Text("Headline").skeletonNode(kind: .text(lineHeight: 20))
Text("Subtitle").skeletonNode(kind: .text(lineHeight: 14))
}
.smartSkeleton(
true,
config: ShimmerConfig(
gradient: Gradient(colors: [.clear, .blue.opacity(0.4), .clear]),
skeletonColor: .blue.opacity(0.15),
speed: 0.9,
angle: .degrees(35)
)
)Text("Auto detected skeleton")
.font(.body)
.skeletonNode()RoundedRectangle(cornerRadius: 16)
.frame(height: 60)
.skeletonNode(cornerRadius: 20)Text("Name")
.skeletonNode(kind: .text(lineHeight: 18))
Image(systemName: "person.crop.circle.fill")
.resizable()
.frame(width: 40, height: 40)
.skeletonNode(kind: .image)
Rectangle()
.frame(height: 80)
.skeletonNode(kind: .generic)AsyncImage(url: URL(string: "https://example.com/cover.jpg")) { image in
image.resizable().scaledToFill()
} placeholder: {
Color.gray
}
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 18))
.skeletonNode(cornerRadius: 18, kind: .image)ForEach(items, id: \.id) { item in
HStack {
Circle().frame(width: 44, height: 44).skeletonNode(kind: .image)
Text(item.title).skeletonNode(kind: .text(lineHeight: 16))
}
.skeletonID(item.id)
}VStack(spacing: 12) {
Circle()
.frame(width: 48, height: 48)
.skeletonNode(shape: .circle)
Text("Name")
.skeletonNode(kind: .text(lineHeight: 16), shape: .capsule)
Rectangle()
.frame(height: 56)
.skeletonNode(shape: .roundedRectangle(cornerRadius: 14))
}
.smartSkeleton(true)Render shimmer only on specific skeleton scopes.
VStack(alignment: .leading, spacing: 10) {
Text("Header")
.skeletonNode(scope: "header")
Text("Body line 1")
.skeletonNode(scope: "body")
Text("Body line 2")
.skeletonNode(scope: "body")
Button("Retry") {}
.skeletonNode(scope: "actions")
}
.smartSkeleton(
true,
includeScopes: ["header", "body"]
)content.smartSkeleton(isLoading)or
content.smartSkeleton(isLoading, includeScopes: nil)When enabled, text skeleton blocks can be split into multiple pill lines.
let config = ShimmerKit.config(
shimmerColor: .mint,
skeletonColor: .mint.opacity(0.2),
angle: .degrees(30),
splitMultilineText: true
)
content.smartSkeleton(isLoading, config: config)let config = ShimmerConfig(
gradient: Gradient(colors: [.clear, .white.opacity(0.4), .clear]),
skeletonColor: .gray.opacity(0.2),
speed: 1.0,
angle: .degrees(20)
)
content.smartSkeleton(isLoading, config: config)When enabled, text skeletons are heuristically grouped into title/subtitle styles (subtitle lines become slightly shorter).
let config = ShimmerKit.config(
shimmerColor: .indigo,
skeletonColor: .indigo.opacity(0.18),
splitMultilineText: true,
enableSemanticGrouping: true
)
content.smartSkeleton(isLoading, config: config)let config = ShimmerConfig(
gradient: Gradient(colors: [.clear, .white.opacity(0.35), .clear]),
skeletonColor: .gray.opacity(0.2),
speed: 1.0,
angle: .degrees(20),
splitMultilineText: true,
enableSemanticGrouping: false
)
content.smartSkeleton(isLoading, config: config)Enable this to route hidden content through a Layout-based placement path.
let config = ShimmerKit.config(
shimmerColor: .blue,
skeletonColor: .blue.opacity(0.18),
splitMultilineText: true,
enableSemanticGrouping: true,
useLayoutProtocolIntegration: true
)
content.smartSkeleton(isLoading, config: config)let config = ShimmerConfig(
gradient: Gradient(colors: [.clear, .white.opacity(0.35), .clear]),
skeletonColor: .gray.opacity(0.2),
speed: 1.0,
angle: .degrees(20),
splitMultilineText: false,
enableSemanticGrouping: false,
useLayoutProtocolIntegration: false
)
content.smartSkeleton(isLoading, config: config)VStack {
Text("Default config")
.skeletonNode()
}
.smartSkeleton(true, config: ShimmerKit.defaultConfig)18) ShimmerKit.config(gradient:skeletonColor:speed:angle:splitMultilineText:enableSemanticGrouping:useLayoutProtocolIntegration:)
let config = ShimmerKit.config(
gradient: Gradient(colors: [.clear, .purple.opacity(0.45), .clear]),
skeletonColor: .purple.opacity(0.18),
speed: 1.1,
angle: .degrees(60),
splitMultilineText: true,
enableSemanticGrouping: true,
useLayoutProtocolIntegration: true
)
content.smartSkeleton(isLoading, config: config)19) ShimmerKit.config(shimmerColor:skeletonColor:shimmerOpacity:speed:angle:splitMultilineText:enableSemanticGrouping:useLayoutProtocolIntegration:)
let config = ShimmerKit.config(
shimmerColor: .mint,
skeletonColor: .mint.opacity(0.2),
shimmerOpacity: 0.5,
speed: 1.0,
angle: .degrees(25),
splitMultilineText: true,
enableSemanticGrouping: true,
useLayoutProtocolIntegration: true
)
content.smartSkeleton(isLoading, config: config)let custom = ShimmerConfig(
gradient: Gradient(colors: [.clear, .orange.opacity(0.4), .clear]),
skeletonColor: .orange.opacity(0.16),
speed: 0.85,
angle: .degrees(45),
splitMultilineText: true,
enableSemanticGrouping: true,
useLayoutProtocolIntegration: true
)21) ShimmerConfig(shimmerColor:skeletonColor:shimmerOpacity:speed:angle:splitMultilineText:enableSemanticGrouping:useLayoutProtocolIntegration:)
let quick = ShimmerConfig(
shimmerColor: .teal,
skeletonColor: .teal.opacity(0.15),
shimmerOpacity: 0.4,
speed: 1.25,
angle: .degrees(30),
splitMultilineText: true,
enableSemanticGrouping: true,
useLayoutProtocolIntegration: true
)import SwiftUI
import ShimmerKit
struct UserRow: View {
let title: String
var body: some View {
HStack(spacing: 12) {
Circle()
.frame(width: 44, height: 44)
.skeletonNode(kind: .image)
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.headline)
.skeletonNode(kind: .text(lineHeight: 18))
Text("Subtitle")
.font(.subheadline)
.skeletonNode(kind: .text(lineHeight: 14))
}
}
.padding(.vertical, 6)
}
}
struct UsersScreen: View {
@State private var isLoading = true
private let placeholders = Array(0..<6)
private let users = ["Jane", "Alex", "Mia"]
var body: some View {
List {
ForEach(isLoading ? placeholders.map(String.init) : users, id: \.self) { value in
UserRow(title: value)
.skeletonID(value)
}
}
.smartSkeleton(
isLoading,
config: ShimmerKit.config(
shimmerColor: .cyan,
skeletonColor: .cyan.opacity(0.15),
shimmerOpacity: 0.45,
speed: 1.0,
angle: .degrees(40),
splitMultilineText: true,
enableSemanticGrouping: true,
useLayoutProtocolIntegration: true
)
)
.task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
isLoading = false
}
}
}Use ready-made configs for common loading styles:
content.smartSkeleton(isLoading, config: ShimmerKit.config(.default))
content.smartSkeleton(isLoading, config: ShimmerKit.config(.subtle))
content.smartSkeleton(isLoading, config: ShimmerKit.config(.feedLoading))
content.smartSkeleton(isLoading, config: ShimmerKit.config(.detailPage))Profile intent:
.default→ balanced baseline config.subtle→ softer highlight + slower animation.feedLoading→ stronger shimmer for list/feed placeholders.detailPage→ richer shimmer with advanced toggles enabled
Text("Hello")
.font(.system(size: 56, weight: .heavy, design: .rounded))
.shimmerText(
config: ShimmerConfig(
gradient: Gradient(colors: [.clear, .pink.opacity(0.9), .orange.opacity(0.9), .clear]),
speed: 1.0,
angle: .degrees(20)
),
baseColor: .gray.opacity(0.35)
)VStack(alignment: .leading, spacing: 10) {
Text("Title").font(.title3.weight(.bold))
HStack {
VStack(alignment: .leading) {
Text("Left")
Text("Stack")
}
Spacer()
Text("Right")
}
}
.shimmerTextSweep(
config: ShimmerConfig(
gradient: Gradient(colors: [.clear, .cyan.opacity(0.9), .mint.opacity(0.9), .clear]),
speed: 1.2,
angle: .degrees(28)
),
baseColor: .gray.opacity(0.32)
)VStack(alignment: .leading) {
VStack(alignment: .leading) {
Text("Excluded block")
Text("No shimmer here")
}
.shimmerTextSweepExclude()
Text("Still shimmering")
}
.shimmerTextSweep(config: ShimmerKit.config(.subtle))| Type | Behavior |
|---|---|
.text |
Rounded pill (auto line height) |
.image |
Rounded rectangle (12 default radius) |
.generic |
Default rounded rectangle |
ShimmerKit automatically infers:
- Text → height < 20
- Image → width ≈ height
- Generic → everything else
You can override anytime.
- iOS 16+
- Swift 5.9+
- SwiftUI
Fully compatible with Swift 6 strict concurrency:
Sendablemodels- Safe
PreferenceKeyusage - No unsafe global state
Input Layer
↓
Skeleton Nodes (layout capture)
↓
Processing Engine (merge + group)
↓
Renderer (shapes + shimmer)
Core/
Skeleton/
Modifiers/
Containers/
Engine/
Shapes/
Extensions/
Utilities/
- ❌ Not a simple
.shimmer()modifier - ❌ Not manual skeleton UI
- ❌ Not tied to specific layouts
Most libraries:
“Draw grey rectangles”
ShimmerKit:
Reconstructs your UI structure automatically
VStack(alignment: .leading, spacing: 12) {
Text("Product Title")
Text("Subtitle")
HStack {
Text("₹99")
Text("₹199")
}
}
.smartSkeleton(true)- 🔥 Multi-line text splitting
- ⚡ SwiftUI Layout protocol integration
- 🎯 Partial skeleton rendering
- 🧠 Semantic grouping (title vs subtitle detection)
- 🎨 Multiple shape support (circle, capsule, etc.)
PRs are welcome—but keep it:
- clean
- modular
- concurrency-safe
MIT License
Built with intent, not shortcuts.
If your skeleton UI breaks when your layout changes, you built it wrong.
ShimmerKit fixes that.