Swift PiecesFree
Feedback

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.

Free · MIT + Commons ClauseiOS 17.0+loadingskeletonshimmerplaceholdererrorredacted
Type
SkeletonLoader
Files
SkeletonLoader.swift
Depends on
Nothing (Apple frameworks only)
Version
2.0.0

In a full screen · Swift Pieces Pro

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; staggerIndex delays that by 60 ms per row so lists reveal top to bottom.
  • isFailed stops the sweep and dims the bones so the slot reads as empty; flip isLoading back on to retry.
  • Style sets the bone fill, the sweep highlight, the period and the failed opacity, with house palette defaults that adapt to light and dark. Pass the same style to SkeletonLoader bones and .skeleton so 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

ParameterDescription
shape.rounded(radius), .circle, .capsule, or .text(lines:) for a stack of text lines with a shorter last line.
isFailedStops the sweep and dims the bone so the slot reads as empty.
styleBone and highlight colors and the sweep period. .standard is a solid warm grey that adapts to light and dark.

Source

SkeletonLoader.swift
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

$npx swiftpieces add SkeletonLoader

Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected

>Add the Swift Pieces "Skeleton Loader" piece to my app

Or copy the source above into your app.

Building a whole app? See Swift Pieces Pro →