Skip to content

Loading States

Daniel S edited this page Jul 2, 2026 · 1 revision

Loading States

DesignFoundation ships two dedicated loading components: DFSkeleton for content placeholders and DFProgressBar for measurable or indeterminate progress feedback.

Related pages: Theming · Buttons · Layout · Alerts-and-Toasts


1. Overview — Skeleton vs Progress Bar

Situation Use Reason
Content is loading and duration is unknown DFSkeleton Preserves layout, reduces perceived wait
File upload with byte progress DFProgressBar Gives measurable feedback
Network request with no progress signal DFProgressBar(value: nil) Communicates "something is happening"
List of items fetching from API DFSkeleton rows Matches shape of real rows
Multi-step wizard DFProgressBar Shows position in a known sequence
Image loading DFSkeleton(.rounded) Holds space with the right shape
Data exists; processing happening DFProgressBar Work is being done on known data

Rule of thumb: reach for DFSkeleton when waiting for data, and DFProgressBar when tracking work.


2. DFSkeleton

DFSkeleton renders an animated shimmer placeholder. The shimmer runs automatically and respects accessibilityReduceMotion — when enabled, it renders as a static muted rectangle.

2.1 Shape Variants

Shape Syntax Best For
.rectangle (default) DFSkeleton(width: 200, height: 16) Text lines, banners
.circle DFSkeleton(width: 40, height: 40, shape: .circle) Avatars, status dots
.rounded DFSkeleton(width: 120, height: 120, shape: .rounded) Thumbnail images, chips

All shapes use theme.colors.border at ~20% opacity, animating to ~40%.

2.2 Sizing Guidelines

Make the skeleton exactly the size of the content it will replace to prevent layout shift:

// Correct — skeleton matches the real component dimensions
DFSkeleton(width: 160, height: 20)  // will be replaced by a headline DFText

// Proportional widths for unknown content length
GeometryReader { proxy in
    VStack(alignment: .leading, spacing: 8) {
        DFSkeleton(width: proxy.size.width * 0.45, height: 22)  // title
        DFSkeleton(width: proxy.size.width * 0.75, height: 16)  // body line 1
        DFSkeleton(width: proxy.size.width * 0.60, height: 16)  // body line 2
    }
}

2.3 Composing Multiple Skeletons

Skeleton card (avatar + title + body):

DFCard {
    HStack(alignment: .top, spacing: 12) {
        DFSkeleton(width: 44, height: 44, shape: .circle)   // avatar

        VStack(alignment: .leading, spacing: 8) {
            DFSkeleton(width: 120, height: 18)              // name
            DFSkeleton(width: 200, height: 14)              // body line 1
            DFSkeleton(width: 160, height: 14)              // body line 2
            DFSkeleton(width: 100, height: 14)              // body line 3
        }
    }
    .padding()
}

Skeleton list rows:

VStack(spacing: 0) {
    ForEach(0..<4, id: \.self) { index in
        HStack(spacing: 12) {
            DFSkeleton(width: 36, height: 36, shape: .rounded)  // icon
            VStack(alignment: .leading, spacing: 6) {
                DFSkeleton(width: CGFloat([150, 130, 165, 120][index]), height: 16)
                DFSkeleton(width: CGFloat([100, 85, 110, 75][index]),  height: 12)
            }
            Spacer()
            DFSkeleton(width: 24, height: 24, shape: .circle)   // trailing
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 12)
        if index < 3 { DFDivider() }
    }
}

2.4 Transitioning from Skeleton to Real Content

struct ArticleView: View {
    @State private var article: Article?
    @State private var isLoading = true

    var body: some View {
        Group {
            if isLoading {
                ArticleSkeletonView().transition(.opacity)
            } else if let article {
                ArticleContentView(article: article).transition(.opacity)
            }
        }
        .animation(.easeInOut(duration: 0.25), value: isLoading)
        .task {
            article = await ArticleService.fetch()
            isLoading = false
        }
    }
}

2.5 Complete Skeleton Examples

Example A — Text Line Placeholders

struct TextBlockSkeleton: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            DFSkeleton(width: 180, height: 22)   // title
            DFSkeleton(width: 240, height: 15)   // body line 1
            DFSkeleton(width: 210, height: 15)   // body line 2
            DFSkeleton(width: 155, height: 15)   // body line 3 (shorter)
        }
        .padding()
    }
}

Example B — Profile Card Skeleton

struct ProfileCardSkeleton: View {
    @Environment(\.dfTheme) private var theme

    var body: some View {
        DFCard(padding: theme.spacing.lg) {
            HStack(alignment: .center, spacing: theme.spacing.md) {
                DFSkeleton(width: 52, height: 52, shape: .circle)   // avatar

                VStack(alignment: .leading, spacing: theme.spacing.sm) {
                    DFSkeleton(width: 130, height: 18)               // name
                    DFSkeleton(width: 90, height: 13)                // subtitle
                }

                Spacer()
                DFSkeleton(width: 60, height: 26, shape: .rounded)  // badge area
            }
        }
        .padding(.horizontal)
    }
}

Example C — List of 4 Skeleton Rows

struct ContactListSkeleton: View {
    var body: some View {
        VStack(spacing: 0) {
            ForEach(0..<4, id: \.self) { index in
                HStack(spacing: 14) {
                    DFSkeleton(width: 40, height: 40, shape: .circle)
                    VStack(alignment: .leading, spacing: 7) {
                        DFSkeleton(width: CGFloat([150, 130, 165, 120][index]), height: 16)
                        DFSkeleton(width: CGFloat([100, 85, 110, 75][index]),  height: 12)
                    }
                    Spacer()
                    DFSkeleton(width: 14, height: 14, shape: .circle)
                }
                .padding(.horizontal, 16)
                .padding(.vertical, 13)
                if index < 3 { DFDivider() }
            }
        }
    }
}

Example D — Image / Photo Placeholder

struct PhotoGridSkeleton: View {
    let columns = [GridItem(.flexible()), GridItem(.flexible())]

    var body: some View {
        LazyVGrid(columns: columns, spacing: 12) {
            ForEach(0..<6, id: \.self) { _ in
                DFSkeleton(width: 160, height: 120, shape: .rounded)
            }
        }
        .padding()
    }
}

// Full-width banner
struct HeroBannerSkeleton: View {
    var body: some View {
        GeometryReader { proxy in
            DFSkeleton(width: proxy.size.width, height: 220, shape: .rounded)
        }
        .frame(height: 220)
        .padding(.horizontal)
    }
}

Example E — Full Skeleton-to-Content Transition

struct FeedItemView: View {
    @State private var post: Post?
    @State private var loaded = false

    var body: some View {
        ZStack {
            if !loaded {
                ProfileCardSkeleton().transition(.opacity)
            } else if let post {
                PostCardView(post: post).transition(.opacity)
            }
        }
        .animation(.easeOut(duration: 0.3), value: loaded)
        .task {
            try? await Task.sleep(for: .seconds(1.5))
            post = Post(author: "Jamie Lin", body: "Just shipped a new update!")
            withAnimation { loaded = true }
        }
    }
}

3. DFProgressBar

DFProgressBar tracks measurable or indeterminate progress.

API

DFProgressBar(value: 0.7)                             // 70% complete
DFProgressBar(value: nil)                              // indeterminate (pulsing)
DFProgressBar(value: progress, label: "Uploading…")   // with label

// Styles
.dfProgressBarStyle(.linear)    // default — horizontal bar
.dfProgressBarStyle(.circular)  // circular ring

// Modifiers
.tint(theme.colors.primary)
.trackColor(theme.colors.border)
.lineWidth(6)                   // circular style only
.frame(width: 64, height: 64)   // size circular rings with frame

3.1 Linear vs Circular

Style Best For
.linear File uploads, step progress, storage meters
.circular Dashboard metrics, compact status rings
// Linear — fills its container
DFProgressBar(value: 0.65).dfProgressBarStyle(.linear).padding(.horizontal)

// Circular — wrap in a fixed frame
DFProgressBar(value: 0.65).dfProgressBarStyle(.circular).frame(width: 60, height: 60)

3.2 Determinate vs Indeterminate

DFProgressBar(value: 0.4)    // 40% — determinate
DFProgressBar(value: nil)    // indeterminate — pulsing animation

The indeterminate animation also respects accessibilityReduceMotion.

3.3 Custom Tint and Track Color

DFProgressBar(value: 0.55)
    .tint(.green)
    .trackColor(Color.green.opacity(0.15))

3.4 Complete Progress Bar Examples

Example A — File Upload Progress Bar

struct FileUploadProgressView: View {
    @State private var uploadProgress: Double = 0.0
    @State private var phase: UploadPhase = .idle

    enum UploadPhase { case idle, uploading, complete }

    var body: some View {
        DFCard {
            VStack(alignment: .leading, spacing: 12) {
                HStack {
                    DFIcon("arrow.up.doc.fill")
                    DFText("quarterly-report.pdf", style: .headline)
                    Spacer()
                    DFText(statusLabel, style: .caption)
                }

                if phase == .uploading {
                    DFProgressBar(
                        value: uploadProgress,
                        label: "\(Int(uploadProgress * 100))% — \(formattedMB) of 24.3 MB"
                    )
                    .dfProgressBarStyle(.linear)
                    .tint(.blue)
                } else if phase == .complete {
                    DFProgressBar(value: 1.0).dfProgressBarStyle(.linear).tint(.green)
                }

                if phase == .idle {
                    DFButton("Upload", icon: "arrow.up") {
                        Task { await runUpload() }
                    }
                }
            }
            .padding()
        }
    }

    private var statusLabel: String {
        switch phase {
        case .idle: return "Ready"; case .uploading: return "Uploading"; case .complete: return "Complete"
        }
    }

    private var formattedMB: String { String(format: "%.1f MB", uploadProgress * 24.3) }

    private func runUpload() async {
        phase = .uploading
        for step in 1...40 {
            try? await Task.sleep(for: .milliseconds(80))
            await MainActor.run { uploadProgress = Double(step) / 40.0 }
        }
        phase = .complete
    }
}

Example B — Circular Progress Ring on a Dashboard Metric Card

struct StorageMetricCard: View {
    @Environment(\.dfTheme) private var theme
    let used: Double   // 0.0 – 1.0
    let totalGB: Int

    var body: some View {
        DFCard(padding: theme.spacing.lg) {
            VStack(spacing: theme.spacing.md) {
                DFProgressBar(value: used, label: "\(Int(used * 100))%")
                    .dfProgressBarStyle(.circular)
                    .lineWidth(7)
                    .tint(theme.colors.primary)
                    .frame(width: 90, height: 90)

                VStack(spacing: 4) {
                    DFText("Storage Used", style: .caption)
                    DFText("\(Int(used * Double(totalGB))) / \(totalGB) GB", style: .headline)
                }
            }
        }
    }
}

Example C — Indeterminate Progress During Network Request

struct SyncStatusView: View {
    @State private var isSyncing = false
    @State private var syncComplete = false

    var body: some View {
        DFCard {
            VStack(spacing: 14) {
                HStack {
                    DFIcon("arrow.triangle.2.circlepath")
                    DFText(isSyncing ? "Syncing contacts…" : "Contacts", style: .headline)
                    Spacer()
                }

                if isSyncing && !syncComplete {
                    DFProgressBar(value: nil, label: "Contacting server")
                        .dfProgressBarStyle(.linear)
                }

                if syncComplete {
                    DFBadge(text: "Up to date", color: .green)
                }

                if !isSyncing {
                    DFButton("Sync Now", icon: "arrow.triangle.2.circlepath") {
                        Task { await syncContacts() }
                    }
                }
            }
            .padding()
        }
    }

    private func syncContacts() async {
        isSyncing = true; syncComplete = false
        try? await Task.sleep(for: .seconds(3))
        isSyncing = false; syncComplete = true
    }
}

Example D — Step Progress (Multi-Step Form: 2 of 5 Steps)

struct StepProgressHeader: View {
    @Environment(\.dfTheme) private var theme
    let currentStep: Int   // 1-based
    let totalSteps: Int

    private var progress: Double {
        Double(currentStep - 1) / Double(totalSteps - 1)
    }

    private let titles = ["Account", "Profile", "Preferences", "Review", "Done"]

    var body: some View {
        VStack(alignment: .leading, spacing: theme.spacing.sm) {
            HStack {
                DFText("Step \(currentStep) of \(totalSteps)", style: .caption)
                Spacer()
                DFText(titles[min(currentStep - 1, titles.count - 1)], style: .caption)
            }
            DFProgressBar(value: progress)
                .dfProgressBarStyle(.linear)
                .tint(theme.colors.primary)
        }
        .padding(.horizontal)
    }
}

Example E — Storage Usage Bar with Color-Coded Threshold

struct StorageUsageBar: View {
    @Environment(\.dfTheme) private var theme
    let usedBytes: Double
    let totalBytes: Double

    private var fraction: Double { min(usedBytes / totalBytes, 1.0) }

    private var tintColor: Color {
        switch fraction {
        case ..<0.7: return theme.colors.success
        case ..<0.9: return theme.colors.warning
        default:     return theme.colors.error
        }
    }

    private var formattedUsed: String {
        ByteCountFormatter.string(fromByteCount: Int64(usedBytes), countStyle: .file)
    }

    private var formattedTotal: String {
        ByteCountFormatter.string(fromByteCount: Int64(totalBytes), countStyle: .file)
    }

    var body: some View {
        DFCard {
            VStack(alignment: .leading, spacing: 10) {
                HStack {
                    DFText("Storage", style: .headline)
                    Spacer()
                    DFBadge(
                        text: fraction < 0.7 ? "Plenty of space"
                            : fraction < 0.9 ? "Storage filling up"
                            : "Almost full",
                        color: tintColor
                    )
                }
                DFProgressBar(
                    value: fraction,
                    label: "\(formattedUsed) of \(formattedTotal) used"
                )
                .dfProgressBarStyle(.linear)
                .tint(tintColor)
                .trackColor(theme.colors.border)
                .animation(.easeInOut(duration: 0.4), value: fraction)
            }
            .padding()
        }
    }
}

4. Phased Loading — Skeleton Then Progress Bar

Many features go through two phases: fetching (data in transit → skeleton) and processing (local work → progress bar).

enum LoadPhase: Equatable {
    case idle
    case fetching
    case processing(Double)
    case ready
}

struct DocumentImportView: View {
    @State private var phase: LoadPhase = .idle

    var body: some View {
        DFCard {
            VStack(alignment: .leading, spacing: 14) {
                DFText("Import Document", style: .headline)

                // Phase 1: skeleton while server responds
                if case .fetching = phase {
                    VStack(alignment: .leading, spacing: 10) {
                        DFSkeleton(width: 200, height: 18)
                        DFSkeleton(width: 120, height: 13)
                        DFSkeleton(width: 260, height: 100, shape: .rounded)
                    }
                    .transition(.opacity)
                }

                // Phase 2: progress bar while processing
                if case .processing(let pct) = phase {
                    VStack(alignment: .leading, spacing: 8) {
                        DFText("Processing document…", style: .caption)
                        DFProgressBar(value: pct, label: "\(Int(pct * 100))%")
                            .dfProgressBarStyle(.linear)
                    }
                    .transition(.opacity)
                }

                if case .ready = phase {
                    DFBadge(text: "Ready to review", color: .green).transition(.opacity)
                }

                if case .idle = phase {
                    DFButton("Import", icon: "square.and.arrow.down") {
                        Task { await runImport() }
                    }
                }
            }
            .padding()
            .animation(.easeInOut(duration: 0.25), value: phase)
        }
    }

    private func runImport() async {
        withAnimation { phase = .fetching }
        try? await Task.sleep(for: .seconds(2))

        for step in 1...20 {
            try? await Task.sleep(for: .milliseconds(80))
            await MainActor.run { phase = .processing(Double(step) / 20.0) }
        }

        withAnimation { phase = .ready }
    }
}

5. Accessibility

Loading states are invisible to VoiceOver unless explicitly announced.

Announce Loading Started

.task {
    AccessibilityNotification.Announcement("Loading contacts").post()
    contacts = await ContactService.fetch()
}

Announce Completion

AccessibilityNotification.Announcement("\(contacts.count) contacts loaded").post()

Hide Skeleton from VoiceOver

if isLoading {
    ProfileCardSkeleton()
        .accessibilityHidden(true)
} else {
    ProfileCardView(profile: profile)
}

Progress Bar Accessibility Label

DFProgressBar(value: uploadProgress)
    .accessibilityLabel("File upload progress, \(Int(uploadProgress * 100)) percent complete")
    .accessibilityValue(uploadProgress == 1.0 ? "Upload complete" : "")

// Indeterminate
DFProgressBar(value: nil)
    .accessibilityLabel("Syncing contacts")
    .accessibilityValue("In progress")

Announce When Complete

.onChange(of: uploadProgress) { _, newValue in
    if newValue >= 1.0 {
        AccessibilityNotification.Announcement("Upload complete").post()
    }
}

6. DesignFoundation Pro

DesignFoundation Pro ships fully animated analytics dashboards and multi-metric data loading states — progress rings with center labels, sparkline skeleton loaders, and stat card shimmer composites — that save significant build time.

DesignFoundation Pro


See also: Theming · Buttons · Layout · Alerts-and-Toasts

Clone this wiki locally