```html SwiftUI: How to Build a Widget (iOS 17+, 2026)

How to Build a Widget in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: WidgetKit Updated: May 11, 2026
TL;DR

Add a Widget Extension target, implement AppIntentTimelineProvider to supply TimelineEntry values, then declare your widget using AppIntentConfiguration and a SwiftUI view. iOS schedules reloads automatically via WidgetCenter.

import WidgetKit
import SwiftUI

struct SimpleEntry: TimelineEntry {
    let date: Date
    let message: String
}

struct SimpleProvider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: .now, message: "Hello")
    }
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
        completion(SimpleEntry(date: .now, message: "Hello"))
    }
    func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
        let entry = SimpleEntry(date: .now, message: "Live data")
        completion(Timeline(entries: [entry], policy: .atEnd))
    }
}

struct SimpleWidgetView: View {
    var entry: SimpleEntry
    var body: some View {
        Text(entry.message).font(.headline).containerBackground(.fill.tertiary, for: .widget)
    }
}

@main
struct SimpleWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "com.example.simple", provider: SimpleProvider()) { entry in
            SimpleWidgetView(entry: entry)
        }
        .configurationDisplayName("My Widget")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

Full implementation

The example below uses AppIntentConfiguration — the iOS 17+ way to build user-configurable widgets — so users can pick which stat to display right from the widget gallery without opening the app. The TimelineEntry carries all data the view needs, keeping the view layer purely declarative. WidgetCenter.shared.reloadTimelines in the main app forces an immediate refresh whenever new data arrives.

// WidgetExtension/StatsWidget.swift
import WidgetKit
import SwiftUI
import AppIntents

// MARK: - App Intent (user configuration)
struct StatsConfigIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Stats Widget"
    static var description = IntentDescription("Shows your daily stats.")

    @Parameter(title: "Stat Type", default: .steps)
    var statType: StatType
}

enum StatType: String, AppEnum {
    case steps, calories, distance
    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Stat Type")
    static var caseDisplayRepresentations: [StatType: DisplayRepresentation] = [
        .steps:    "Steps",
        .calories: "Calories",
        .distance: "Distance"
    ]
}

// MARK: - Timeline Entry
struct StatsEntry: TimelineEntry {
    let date: Date
    let statType: StatType
    let value: Double
    let unit: String
}

// MARK: - Provider
struct StatsProvider: AppIntentTimelineProvider {
    typealias Entry = StatsEntry
    typealias Intent = StatsConfigIntent

    func placeholder(in context: Context) -> StatsEntry {
        StatsEntry(date: .now, statType: .steps, value: 8_200, unit: "steps")
    }

    func snapshot(for configuration: StatsConfigIntent,
                  in context: Context) async -> StatsEntry {
        await makeEntry(for: configuration)
    }

    func timeline(for configuration: StatsConfigIntent,
                  in context: Context) async -> Timeline<StatsEntry> {
        let entry = await makeEntry(for: configuration)
        // Refresh at midnight local time
        let midnight = Calendar.current.startOfDay(for: .now).addingTimeInterval(86_400)
        return Timeline(entries: [entry], policy: .after(midnight))
    }

    private func makeEntry(for configuration: StatsConfigIntent) async -> StatsEntry {
        // Replace with real HealthKit / network fetch
        let (value, unit): (Double, String) = switch configuration.statType {
            case .steps:    (10_432, "steps")
            case .calories: (487,    "kcal")
            case .distance: (7.3,    "km")
        }
        return StatsEntry(date: .now, statType: configuration.statType,
                          value: value, unit: unit)
    }
}

// MARK: - Widget View
struct StatsWidgetView: View {
    var entry: StatsEntry
    @Environment(\.widgetFamily) private var family

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Label(entry.statType.rawValue.capitalized, systemImage: iconName)
                .font(.caption.weight(.semibold))
                .foregroundStyle(.secondary)

            Spacer()

            Text(entry.value, format: .number.precision(.fractionLength(0)))
                .font(family == .systemSmall ? .title : .largeTitle)
                .fontWeight(.bold)
                .minimumScaleFactor(0.6)
                .accessibilityLabel("\(Int(entry.value)) \(entry.unit)")

            Text(entry.unit)
                .font(.footnote)
                .foregroundStyle(.secondary)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
        .padding()
        .containerBackground(.fill.tertiary, for: .widget)
    }

    private var iconName: String {
        switch entry.statType {
        case .steps:    "figure.walk"
        case .calories: "flame.fill"
        case .distance: "location.fill"
        }
    }
}

// MARK: - Widget Declaration
@main
struct StatsWidget: Widget {
    let kind = "com.example.StatsWidget"

    var body: some WidgetConfiguration {
        AppIntentConfiguration(kind: kind,
                               intent: StatsConfigIntent.self,
                               provider: StatsProvider()) { entry in
            StatsWidgetView(entry: entry)
        }
        .configurationDisplayName("Daily Stats")
        .description("Track steps, calories, or distance at a glance.")
        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge,
                            .accessoryCircular, .accessoryRectangular])
    }
}

// MARK: - Preview
#Preview(as: .systemSmall) {
    StatsWidget()
} timeline: {
    StatsEntry(date: .now, statType: .steps, value: 10_432, unit: "steps")
    StatsEntry(date: .now, statType: .calories, value: 487, unit: "kcal")
}

How it works

  1. StatsConfigIntent (AppIntentTimelineProvider) — Declaring a WidgetConfigurationIntent with an @Parameter tells WidgetKit to show a configuration UI in the widget gallery. The user picks "Steps," "Calories," or "Distance" without ever launching the app.
  2. timeline(for:in:) refresh policy — Returning .after(midnight) schedules the next reload at the next calendar day boundary. For real-time data, call WidgetCenter.shared.reloadTimelines(ofKind:) from the main app after a background fetch completes.
  3. containerBackground(.fill.tertiary, for: .widget) — Required on iOS 17+ so the widget renders correctly on the home screen, lock screen, and StandBy. Omitting this crashes older-style widgets on iOS 17.
  4. @Environment(\.widgetFamily) — Reads the current size at render time so the same view adapts its font size between .systemSmall and .systemLarge families without needing separate view structs.
  5. #Preview(as:) with a timeline: closure — The iOS 17+ widget preview macro accepts multiple timeline entries so you can flip through states directly in Xcode canvas, replicating how WidgetKit rotates entries over time.

Variants

Lock screen / StandBy (accessory families)

Accessory widgets use the same provider but need a separate view branch because they render in grayscale and have tighter space constraints. Add .accessoryCircular and .accessoryRectangular to supportedFamilies, then branch on widgetFamily:

struct StatsWidgetView: View {
    var entry: StatsEntry
    @Environment(\.widgetFamily) private var family

    var body: some View {
        switch family {
        case .accessoryCircular:
            Gauge(value: entry.value, in: 0...15_000) {
                Image(systemName: "figure.walk")
            } currentValueLabel: {
                Text(entry.value / 1_000, format: .number.precision(.fractionLength(1)))
            }
            .gaugeStyle(.accessoryCircular)
            .containerBackground(.clear, for: .widget)

        case .accessoryRectangular:
            HStack {
                Image(systemName: "figure.walk")
                VStack(alignment: .leading) {
                    Text("Steps").font(.caption2)
                    Text(entry.value, format: .number).font(.headline)
                }
            }
            .containerBackground(.clear, for: .widget)

        default:
            // … existing home-screen layout
            Text(entry.value, format: .number).containerBackground(.fill.tertiary, for: .widget)
        }
    }
}

Forcing a refresh from the main app

After a network sync or user action in your main app target, invalidate the widget timeline immediately:

import WidgetKit

// Call this after saving new data (e.g., after a HealthKit background delivery)
func refreshWidget() {
    WidgetCenter.shared.reloadTimelines(ofKind: "com.example.StatsWidget")
    // Or reload everything:
    // WidgetCenter.shared.reloadAllTimelines()
}

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a home screen and lock screen widget in SwiftUI for iOS 17+.
Use WidgetKit, AppIntentTimelineProvider, AppIntentConfiguration, and containerBackground.
Support systemSmall, systemMedium, accessoryCircular, and accessoryRectangular families.
Share data with the main app via an App Group UserDefaults suite.
Make it accessible (VoiceOver labels with units on all numeric Text views).
Add a #Preview(as:) with realistic sample data for at least two timeline entries.

In Soarias's Build phase, paste this prompt into the active session after scaffolding your Widget Extension target — Claude Code will wire up the provider, shared data layer, and preview in one shot, leaving you to swap in your real data source.

Related

FAQ

Does this work on iOS 16?

Partially. AppIntentConfiguration and the async AppIntentTimelineProvider protocol require iOS 17+. On iOS 16 you must fall back to IntentConfiguration (backed by a Siri Intent Definition file) and the callback-based IntentTimelineProvider. The containerBackground modifier is also iOS 17+ — without it, widgets built targeting iOS 16 still render correctly on iOS 16 but must handle the modifier conditionally when running on iOS 17+. If your minimum deployment target is iOS 17, you can use everything shown here without any #available guards.

How do I pass data from my SwiftUI app into the widget?

The widget extension runs in a separate process and cannot read from the main app's UserDefaults.standard or its sandboxed files. The correct approach is to enable an App Group capability on both your main app and the widget extension targets, then write with UserDefaults(suiteName: "group.com.example.app") in the app and read from the same suite in StatsProvider. For larger blobs use a shared FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:) path. After writing, call WidgetCenter.shared.reloadTimelines(ofKind:) so the provider immediately re-fetches the fresh data.

What's the UIKit / WKWebView equivalent?

WidgetKit widgets are always SwiftUI — you cannot use UIKit views or WKWebView inside a widget. The widget view hierarchy is rendered to an image by the system at timeline-entry time; interactive UIKit elements, scrolling, and web content are not supported. Interactive elements (buttons and toggles that run App Intents) became available in iOS 17 via Button(intent:) and Toggle(isOn:intent:), but the general view layer must remain SwiftUI.

Last reviewed: 2026-05-11 by the Soarias team.

```