-
Notifications
You must be signed in to change notification settings - Fork 0
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
| 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.
DFSkeleton renders an animated shimmer placeholder. The shimmer runs automatically and respects accessibilityReduceMotion — when enabled, it renders as a static muted rectangle.
| 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%.
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
}
}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() }
}
}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
}
}
}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()
}
}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)
}
}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() }
}
}
}
}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)
}
}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 }
}
}
}DFProgressBar tracks measurable or indeterminate progress.
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| 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)DFProgressBar(value: 0.4) // 40% — determinate
DFProgressBar(value: nil) // indeterminate — pulsing animationThe indeterminate animation also respects accessibilityReduceMotion.
DFProgressBar(value: 0.55)
.tint(.green)
.trackColor(Color.green.opacity(0.15))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
}
}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)
}
}
}
}
}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
}
}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)
}
}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()
}
}
}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 }
}
}Loading states are invisible to VoiceOver unless explicitly announced.
.task {
AccessibilityNotification.Announcement("Loading contacts").post()
contacts = await ContactService.fetch()
}AccessibilityNotification.Announcement("\(contacts.count) contacts loaded").post()if isLoading {
ProfileCardSkeleton()
.accessibilityHidden(true)
} else {
ProfileCardView(profile: profile)
}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").onChange(of: uploadProgress) { _, newValue in
if newValue >= 1.0 {
AccessibilityNotification.Announcement("Upload complete").post()
}
}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.
See also: Theming · Buttons · Layout · Alerts-and-Toasts