Skeleton Loader
Solid placeholder bones and a self-masking modifier that turns any layout, color blocks included, into one quiet shape sweeping a soft diagonal highlight on a shared clock, then hands off as content unblurs and rises row by row.
- Type
- SkeletonLoader
- Files
- SkeletonLoader.swift
- Depends on
- Nothing (Apple frameworks only)
- Version
- 2.0.0
Projects
A projects screen that starts as breathing ghost rows and hands off to your real projects.
Standalone placeholder bone. Use .skeleton(isLoading:) to redact real content instead.
Notes
.skeleton(isLoading:)keeps the real view in the tree, hidden, so layout never jumps. The placeholder is a silhouette of the redacted view drawn through an alpha threshold, so text bars, color blocks and symbols all become one solid bone color, whatever colors the content uses.- A soft diagonal highlight sweeps across the bones. Every instance reads the same reference clock, which keeps every skeleton on screen in phase.
- On handoff the placeholder fades while the content unblurs and rises 6 pt;
staggerIndexdelays that by 60 ms per row so lists reveal top to bottom. isFailedstops the sweep and dims the bones so the slot reads as empty; flipisLoadingback on to retry.Stylesets the bonefill, the sweephighlight, theperiodand the failed opacity, with house palette defaults that adapt to light and dark. Pass the same style toSkeletonLoaderbones and.skeletonso they match.- Reduce Transparency switches to an opaque fill without the sweep. Under Reduce Motion there is no sweep and the handoff is an instant swap.
- For a whole projects screen that starts as ghost rows and hands off to real content, see the Pro Projects screen.
Usage
SkeletonLoaderExample()Parameters
| Parameter | Description |
|---|---|
shape | .rounded(radius), .circle, .capsule, or .text(lines:) for a stack of text lines with a shorter last line. |
isFailed | Stops the sweep and dims the bone so the slot reads as empty. |
style | Bone and highlight colors and the sweep period. .standard is a solid warm grey that adapts to light and dark. |
Source
import SwiftUI
/// Standalone placeholder bone. Use `.skeleton(isLoading:)` to redact real content instead.
///
/// - Parameters:
/// - shape: `.rounded(radius)`, `.circle`, `.capsule`, or `.text(lines:)` for a stack of text lines with a shorter last line.
/// - isFailed: Stops the sweep and dims the bone so the slot reads as empty.
/// - style: Bone and highlight colors and the sweep period. `.standard` is a solid warm grey that adapts to light and dark.
public struct SkeletonLoader: View {
public enum Kind: Sendable {
case rounded(CGFloat = 12)
case circle
case capsule
case text(lines: Int)
}
/// Colors and timing for bones and redacted content. Defaults follow the Swift Pieces house palette.
public struct Style: Sendable {
/// Solid bone color.
public var fill: Color
/// Color at the center of the sweeping band.
public var highlight: Color
/// Bone color under Reduce Transparency, where the sweep is also removed.
public var opaqueFill: Color
/// Seconds for one sweep across. Every skeleton reads the same clock, so they stay in phase.
public var period: Double
/// Bone opacity once loading has failed.
public var failedOpacity: Double
public init(
fill: Color = Style.adaptive(0xE6E3DB, 0x2F2F2F),
highlight: Color = Style.adaptive(0xF7F5F1, 0x3D3D3D),
opaqueFill: Color = Style.adaptive(0xDEDCD5, 0x333333),
period: Double = 1.6,
failedOpacity: Double = 0.5
) {
self.fill = fill
self.highlight = highlight
self.opaqueFill = opaqueFill
self.period = period
self.failedOpacity = failedOpacity
}
public static let standard = Style()
/// A color that resolves to `light` or `dark` hex by the current appearance.
public static func adaptive(_ light: UInt32, _ dark: UInt32) -> Color {
Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? rgb(dark) : rgb(light) })
}
private static func rgb(_ hex: UInt32) -> UIColor {
UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
}
}
@ScaledMetric(relativeTo: .body) private var lineHeight: CGFloat = 12
private let shape: Kind
private let isFailed: Bool
private let style: Style
public init(shape: Kind = .rounded(), isFailed: Bool = false, style: Style = .standard) {
self.shape = shape
self.isFailed = isFailed
self.style = style
}
public var body: some View {
Group {
if case .text(let lines) = shape {
VStack(alignment: .leading, spacing: lineHeight * 0.75) {
ForEach(0..<max(lines, 1), id: \.self) { index in
Bone(shape: AnyShape(Capsule()), isFailed: isFailed, style: style)
.frame(height: lineHeight)
.frame(maxWidth: .infinity)
.scaleEffect(x: index == lines - 1 && lines > 1 ? 0.62 : 1, anchor: .leading)
}
}
} else {
Bone(shape: base, isFailed: isFailed, style: style)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(isFailed ? "Failed to load" : "Loading")
}
private var base: AnyShape {
switch shape {
case .rounded(let radius): AnyShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
case .circle: AnyShape(Circle())
case .capsule: AnyShape(Capsule())
case .text: AnyShape(Capsule())
}
}
private struct Bone: View {
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
let shape: AnyShape
let isFailed: Bool
let style: Style
var body: some View {
ZStack {
shape.fill(reduceTransparency ? style.opaqueFill : style.fill)
if !isFailed, !reduceTransparency {
Sweep(color: style.highlight, period: style.period).clipShape(shape)
}
}
.opacity(isFailed ? style.failedOpacity : 1)
.animation(.smooth(duration: 0.4), value: isFailed)
}
}
/// Redacts content while loading, then hands off: placeholder fades out as content unblurs and rises.
fileprivate struct Modifier: ViewModifier {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
let isLoading: Bool
let isFailed: Bool
let staggerIndex: Int
let style: Style
private var showsPlaceholder: Bool { isLoading || isFailed }
func body(content: Content) -> some View {
let delay = reduceMotion ? 0 : 0.06 * Double(staggerIndex)
ZStack {
content
.opacity(showsPlaceholder ? 0 : 1)
.blur(radius: showsPlaceholder && !reduceMotion ? 8 : 0)
.offset(y: showsPlaceholder && !reduceMotion ? 6 : 0)
.animation(reduceMotion ? .easeOut(duration: 0.1) : .spring(duration: 0.5, bounce: 0.12).delay(delay), value: showsPlaceholder)
.accessibilityHidden(showsPlaceholder)
if showsPlaceholder {
content
.hidden()
.overlay {
ZStack {
reduceTransparency ? style.opaqueFill : style.fill
if !isFailed, !reduceTransparency {
Sweep(color: style.highlight, period: style.period)
}
}
.mask { Silhouette(content: content) }
}
.opacity(isFailed ? style.failedOpacity : 1)
.animation(.smooth(duration: 0.4), value: isFailed)
.allowsHitTesting(false)
.accessibilityElement(children: .ignore)
.accessibilityLabel(isFailed ? "Failed to load" : "Loading")
.transition(reduceMotion ? .identity : .opacity.animation(.easeOut(duration: 0.25).delay(delay)))
}
}
.allowsHitTesting(!showsPlaceholder)
}
}
/// Every shape the redacted layout draws (text bars, color blocks, symbols) as one fully opaque mask,
/// so the placeholder reads as a single solid bone color whatever the content's own colors and opacities.
private struct Silhouette<Source: View>: View {
let content: Source
var body: some View {
Canvas { context, size in
context.addFilter(.alphaThreshold(min: 0.02, color: .black))
context.addFilter(.blur(radius: 0.4))
if let symbol = context.resolveSymbol(id: 0) {
context.draw(symbol, at: CGPoint(x: size.width / 2, y: size.height / 2))
}
} symbols: {
content.redacted(reason: .placeholder).tag(0)
}
}
}
/// A soft diagonal band. Every instance reads the same reference clock, so all skeletons sweep in phase.
private struct Sweep: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let color: Color
let period: Double
var body: some View {
if !reduceMotion {
TimelineView(.animation) { context in
GeometryReader { proxy in
let width = proxy.size.width
let band = max(72, width * 0.45)
let phase = (context.date.timeIntervalSinceReferenceDate / period).truncatingRemainder(dividingBy: 1)
LinearGradient(
stops: [
.init(color: color.opacity(0), location: 0),
.init(color: color, location: 0.5),
.init(color: color.opacity(0), location: 1),
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: band, height: max(proxy.size.height * 3, 120))
.rotationEffect(.degrees(18))
.offset(x: -band * 1.5 + (width + band * 3) * phase, y: -max(proxy.size.height, 40))
}
}
.allowsHitTesting(false)
}
}
}
}
/// Redacts the view while loading, sweeps it, then hands off to the real content.
///
/// - Parameters:
/// - isLoading: Show the placeholder while `true`.
/// - isFailed: Keep the placeholder but stop the sweep and dim it; flip `isLoading` back on to retry.
/// - staggerIndex: Delays this view's handoff by 60 ms per index so lists reveal top to bottom.
/// - style: Bone color, highlight and sweep period. Defaults to `SkeletonLoader.Style.standard`.
public extension View {
func skeleton(isLoading: Bool, isFailed: Bool = false, staggerIndex: Int = 0, style: SkeletonLoader.Style = .standard) -> some View {
modifier(SkeletonLoader.Modifier(isLoading: isLoading, isFailed: isFailed, staggerIndex: staggerIndex, style: style))
}
}
// MARK: - Example
/// The modifier and the rows it replaces: three rows redact into one quiet shape, then reveal top to bottom.
private struct SkeletonLoaderExample: View {
@State private var loading = true
private let items: [(String, String, String, Color)] = [
("9:30", "Design review", "Studio B · 45 min", Color(red: 1, green: 0.851, blue: 0.463)),
("11:00", "Lunch with Priya", "Ferro Kitchen · 1 h", Color(red: 0.663, green: 0.863, blue: 0.718)),
("16:15", "Ship build 4.2", "Release room · 30 min", Color(red: 0.612, green: 0.761, blue: 1)),
]
var body: some View {
VStack(alignment: .leading, spacing: 22) {
ForEach(items.indices, id: \.self) { index in
let item = items[index]
HStack(spacing: 18) {
Text(item.0)
.font(.system(size: 21, design: .rounded).weight(.bold))
.foregroundStyle(Color(red: 0.078, green: 0.078, blue: 0.078))
.frame(width: 92, height: 76)
.background(item.3, in: .rect(cornerRadius: 24, style: .continuous))
VStack(alignment: .leading, spacing: 4) {
Text(item.1).font(.system(size: 22, weight: .semibold))
Text(item.2).font(.system(size: 19)).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.skeleton(isLoading: loading, staggerIndex: index)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 28)
.background(SkeletonLoader.Style.adaptive(0xF3F2EE, 0x121212))
.onTapGesture { loading.toggle() }
}
}
#Preview("Light") {
SkeletonLoaderExample()
}
#Preview("Dark") {
SkeletonLoaderExample().preferredColorScheme(.dark)
}
#Preview("Bones") {
HStack(spacing: 14) {
SkeletonLoader(shape: .rounded(18)).frame(width: 64, height: 52)
SkeletonLoader(shape: .text(lines: 2))
}
.padding()
}Install
Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.
Terminal
Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected
Or copy the source above into your app.
Building a whole app? See Swift Pieces Pro →
Reaction Toggle
A like or save toggle that presses in, floods its capsule with a solid color block from the symbol outward, bounces the symbol, sends out one fading halo, and can roll a count or float a confirmation pill.
Status Morph
One continuous stroke that spins as a loading arc, closes into a ring, floods into a solid block and draws a check in dark ink on success, or a cross with a nudge on failure, with an optional morphing caption.