SwiftUI for iOS Widgets: Building Home Screen Widgets

SwiftUI for iOS widgets lets you create small, glanceable pieces of content that appear on the Home Screen, Lock Screen, or in Smart Stack areas on iPhone. This article explains how widgets work, how SwiftUI fits into WidgetKit, and how to build reliable widget content that updates on a timeline.

Quick answer: iOS widgets are built with WidgetKit, and their visual content is defined with SwiftUI views. You provide a widget configuration, a timeline of entries, and a SwiftUI-based interface that renders differently for each supported widget size.

Difficulty: Intermediate

You'll understand this better if you know: basic Swift syntax, SwiftUI view composition, and how structs and simple state work in Swift.

1. What Is SwiftUI for iOS Widgets?

SwiftUI for iOS widgets is the combination of WidgetKit and SwiftUI used to build compact, system-managed views that live outside your main app. A widget is not a miniature app screen; it is a snapshot-style interface designed for quick information and limited interaction.

  • WidgetKit handles the widget lifecycle, timelines, sizes, and system placement.
  • SwiftUI defines the widget’s appearance using a declarative view hierarchy.
  • Widgets are refreshed by the system, not continuously by your app.
  • They must be fast, lightweight, and readable at a glance.

Because widgets run in a separate extension, they have stricter rules than normal SwiftUI views. You focus on showing the right data at the right time, rather than supporting full interaction or navigation.

2. Why SwiftUI for iOS Widgets Matters

Widgets help users see useful information without opening an app. That can improve engagement for weather, reminders, activity summaries, media progress, habit tracking, calendars, and many other app categories.

For developers, SwiftUI is important here because it gives you a consistent, declarative way to build widget layouts across different sizes. You can reuse familiar view composition patterns while still respecting the constraints of the widget environment.

Use widgets when your app benefits from fast, glanceable status updates. Do not use them for workflows that require deep navigation, heavy interaction, or frequent live updates, because the system controls refresh timing.

3. Basic Syntax

A widget starts with a timeline provider, a SwiftUI view, and a configuration. The provider supplies entries over time, and the SwiftUI view renders each entry.

Minimal widget structure

The following example shows the core building blocks of a simple widget extension. It uses a static widget that displays a title and a value.

Swift
import WidgetKit
import SwiftUI

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

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), value: "Loading")
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
        completion(SimpleEntry(date: Date(), value: "Preview"))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> Void) {
        let entry = SimpleEntry(date: Date(), value: "Hello, widget")
        let timeline = Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(3600)))
        completion(timeline)
    }
}

struct SimpleWidgetView: View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            Text("Status")
                .font(.headline)
            Text(entry.value)
                .font(.title2)
        }
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

@main
struct SimpleWidgetBundle: WidgetBundle {
    var body: some Widget {
        SimpleWidget()
    }
}

struct SimpleWidget: Widget {
    let kind: String = "SimpleWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            SimpleWidgetView(entry: entry)
        }
        .configurationDisplayName("Simple Widget")
        .description("A small widget that shows a message.")
    }
}

This example shows the basic flow: the provider creates data, the widget configuration wires the provider to a SwiftUI view, and the view renders the entry. In real widgets, the entry usually contains app data such as counts, dates, statuses, or summaries.

Note: The containerBackground(_:for:) modifier marks the widget’s background so the system can remove it where needed, such as in StandBy. On iOS 17 and later, a widget without it shows a warning overlay during development. The examples in this article assume an iOS 17+ deployment target, where WidgetKit also adds default content margins, so the views don’t add their own padding.

4. Step-by-Step Examples

Example 1: Displaying a countdown value

A countdown widget often shows time remaining until an event. The provider can calculate the next update time, while the view formats the value for display.

Swift
import WidgetKit
import SwiftUI

struct CountdownEntry: TimelineEntry {
    let date: Date
    let targetDate: Date
}

struct CountdownProvider: TimelineProvider {
    func placeholder(in context: Context) -> CountdownEntry {
        CountdownEntry(date: Date(), targetDate: Date().addingTimeInterval(7200))
    }

    func getSnapshot(in context: Context, completion: @escaping (CountdownEntry) -> Void) {
        completion(CountdownEntry(date: Date(), targetDate: Date().addingTimeInterval(7200)))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<CountdownEntry>) -> Void) {
        let entry = CountdownEntry(date: Date(), targetDate: Date().addingTimeInterval(7200))
        completion(Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(300))))
    }
}

struct CountdownWidgetView: View {
    let entry: CountdownEntry

    var body: some View {
        VStack(alignment: .leading) {
            Text("Event starts in")
                .font(.caption)
            Text(entry.targetDate, style: .timer)
                .font(.title)
                .monospacedDigit()
        }
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

This widget shows a live-looking countdown, but the system still controls refresh behavior. The timer-style text can update visually, while the timeline determines when the widget receives new data.

Example 2: Supporting multiple widget sizes

Widgets can render different content depending on the family. This lets you show a compact summary in small sizes and richer information in medium or large sizes.

Swift
struct FamilyAwareWidgetView: View {
    let entry: SimpleEntry
    let family: WidgetFamily

    var body: some View {
        Group {
            switch family {
            case .systemSmall:
                Text(entry.value)
                    .font(.headline)
            case .systemMedium:
                VStack(alignment: .leading) {
                    Text("Summary")
                    Text(entry.value)
                }
            default:
                VStack {
                    Text("Detailed view")
                    Text(entry.value)
                }
            }
        }
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

This approach prevents cramped layouts and helps you tailor the amount of information to the available space.

Example 3: Making the widget configurable

Some widgets let the user choose a setting, such as a favorite location or category. A configurable widget uses an intent-based configuration instead of a fixed static configuration. On iOS 17 and later, use AppIntentConfiguration with a WidgetConfigurationIntent and an AppIntentTimelineProvider, as shown below. The older IntentConfiguration API needs a SiriKit intent definition file and an IntentTimelineProvider; prefer App Intents for new widgets.

Swift
import WidgetKit
import SwiftUI
import AppIntents

struct SelectCategoryIntent: WidgetConfigurationIntent {
    static let title: LocalizedStringResource = "Select Category"

    @Parameter(title: "Category", default: "Work")
    var category: String
}

struct CategoryEntry: TimelineEntry {
    let date: Date
    let category: String
}

struct CategoryProvider: AppIntentTimelineProvider {
    func placeholder(in context: Context) -> CategoryEntry {
        CategoryEntry(date: Date(), category: "Work")
    }

    func snapshot(for configuration: SelectCategoryIntent, in context: Context) async -> CategoryEntry {
        CategoryEntry(date: Date(), category: configuration.category)
    }

    func timeline(for configuration: SelectCategoryIntent, in context: Context) async -> Timeline<CategoryEntry> {
        let entry = CategoryEntry(date: Date(), category: configuration.category)
        return Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(3600)))
    }
}

struct ConfigurableWidget: Widget {
    let kind: String = "ConfigurableWidget"

    var body: some WidgetConfiguration {
        AppIntentConfiguration(kind: kind, intent: SelectCategoryIntent.self, provider: CategoryProvider()) { entry in
            Text(entry.category)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("Category")
        .description("Shows the category you choose.")
    }
}

Configurable widgets are useful when the same widget type needs to serve different users or different data sources. People change the setting by editing the widget. On iOS 18 and later, add .promptsForUserConfiguration() to present the configuration UI automatically after the widget is added.

Example 4: Adding a deep link to open the app

Widgets are not fully interactive like app screens. Since iOS 17, they can run simple actions in place with Button and Toggle controls backed by App Intents, and any widget can open a destination in your app. A deep link is useful for taking the user to the relevant place after a tap.

Swift
struct LinkWidgetView: View {
    let entry: SimpleEntry

    var body: some View {
        Link(destination: URL(string: "myapp://details")!) {
            VStack {
                Text("Open details")
                Text(entry.value)
            }
        }
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

This pattern keeps the widget glanceable while still giving users a path to the full experience inside the app.

5. Practical Use Cases

  • Display a user’s next calendar event, with the widget refreshing on a timeline instead of polling constantly.
  • Show a habit streak, reading progress, or daily goal completion summary at a glance.
  • Expose current weather, battery status, or delivery tracking updates in a compact layout.
  • Surface a user-selected category, such as a favorite team score, news topic, or project status.
  • Provide a quick entry point into the app, such as an “open today’s task” or “resume last activity” widget.

Widgets work best when the information is simple, valuable, and time-sensitive enough to justify a Home Screen presence.

6. Common Mistakes

Mistake 1: Treating a widget like a live app screen

Beginners often expect a widget to update continuously whenever app data changes. In reality, the system schedules widget reloads and may delay refreshes to save battery.

Problem: If you assume the widget will update instantly, the UI can look stale even though your app data changed correctly.

Swift
// Incorrect assumption: the widget will always refresh immediately after this changes.
func saveNewValue() {
    // Update shared data here
}

Fix: Request a widget reload after relevant data changes, and design the widget so occasional delay is acceptable.

Swift
import WidgetKit

func saveNewValue() {
    // Update shared data here
    WidgetCenter.shared.reloadAllTimelines()
}

The corrected version asks the system to refresh timelines, which is the right way to notify widgets about new data.

Mistake 2: Using too much text for the available size

Widgets have strict space limits, and long strings often get truncated or become unreadable. A design that looks fine in a view can fail once it is compressed into a small widget family.

Problem: Overly verbose text can wrap badly or disappear behind truncation, making the widget less useful.

Swift
VStack {
    Text("You have completed 12 of 15 tasks for today, and there are 3 remaining tasks to finish before bedtime.")
}

Fix: Use concise copy and move details into the app after the tap.

Swift
VStack {
    Text("12 of 15 tasks complete")
    Text("3 left today")
}

The shorter version fits the widget’s glanceable purpose and stays readable across sizes.

Mistake 3: Forgetting that widget code runs in a separate extension

A widget is not the same target as your main app. Code, resources, and shared data must be available to the widget extension, or the widget may fail to load or show placeholder content only.

Problem: If the widget cannot access the data source or shared container, it may display empty content or fail during snapshot creation.

Swift
// Main-app-only data access that is not shared with the widget target.
let storedValue = "not available to the widget"

Fix: Put shared models, assets, and persisted data in a target that both the app and widget can access, such as an app group container or shared framework.

Swift
let sharedDefaults = UserDefaults(suiteName: "group.com.example.app")
let value = sharedDefaults?.string(forKey: "storedValue")

This works because both targets can read the same shared storage.

7. Best Practices

Practice 1: Design for a single glance

Widgets should answer one question quickly. If a user needs to study the content, the design is usually too dense for a widget.

Swift
VStack(alignment: .leading) {
    Text("Next event")
    Text("Team standup")
}

A focused layout helps the widget stay readable and useful on the Home Screen.

Practice 2: Keep timeline entries small and specific

The entry type should contain only the data the widget needs to render. Large objects make timelines harder to manage and can increase the cost of refreshing.

Swift
struct Entry: TimelineEntry {
    let date: Date
    let title: String
    let count: Int
}

Smaller entries are easier to serialize, fetch, and render inside the extension.

Practice 3: Test every supported widget family

Different families can expose layout problems that are hidden in previews. A compact widget may need a different arrangement than a medium one.

Swift
#if DEBUG
// Preview multiple families in Xcode.
#endif

Testing multiple sizes early prevents clipped text, awkward spacing, and unreadable layouts.

Practice 4: Use shared storage deliberately

If your widget depends on app data, choose a shared storage strategy and keep the contract simple. That reduces the chance of empty widgets caused by mismatched keys or unavailable values.

For many apps, an app group with UserDefaults or a small shared file is enough to begin with.

8. Limitations and Edge Cases

  • Widgets are system-managed and do not refresh on every data change, even if your app updates constantly.
  • Interactivity is limited compared with full SwiftUI views. Since iOS 17, Button(intent:) and Toggle(isOn:intent:) can run an App Intent without opening the app; for anything more complex, open the app with a tap.
  • The widget’s appearance can vary by family, platform, and system context, so a layout that works in one size may break in another.
  • Previews can differ from real device behavior, especially for refresh timing and timeline updates.
  • Some widget features vary by iOS version and family support, so always check platform availability before using newer presentation styles.
  • If your timeline provider is slow, the system may show stale or placeholder content longer than expected.
  • Widgets cannot rely on heavy background work inside the extension; expensive work belongs in the app or a shared service layer.

One common search term here is “widget not updating.” In many cases the problem is not a bug in SwiftUI, but a timeline or data-sharing issue in WidgetKit.

9. Practical Mini Project

Let’s build a small “daily goal” widget that shows how many tasks are complete and opens the app when tapped. This demonstrates the full flow in a realistic but simple way.

Swift
import WidgetKit
import SwiftUI

struct GoalEntry: TimelineEntry {
    let date: Date
    let completed: Int
    let total: Int
}

struct GoalProvider: TimelineProvider {
    func placeholder(in context: Context) -> GoalEntry {
        GoalEntry(date: Date(), completed: 3, total: 10)
    }

    func getSnapshot(in context: Context, completion: @escaping (GoalEntry) -> Void) {
        completion(GoalEntry(date: Date(), completed: 3, total: 10))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<GoalEntry>) -> Void) {
        let entry = GoalEntry(date: Date(), completed: 3, total: 10)
        completion(Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(1800))))
    }
}

struct GoalWidgetView: View {
    let entry: GoalEntry

    var progress: Double {
        Double(entry.completed) / Double(entry.total)
    }

    var body: some View {
        Link(destination: URL(string: "myapp://goals")!) {
            VStack(alignment: .leading, spacing: 8) {
                Text("Daily Goal")
                    .font(.headline)
                ProgressView(value: progress)
                Text("\(entry.completed) of \(entry.total) complete")
                    .font(.caption)
            }
        }
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

struct GoalWidget: Widget {
    let kind = "GoalWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: GoalProvider()) { entry in
            GoalWidgetView(entry: entry)
        }
        .configurationDisplayName("Daily Goal")
        .description("Shows progress toward a daily target.")
    }
}

@main
struct GoalWidgetBundle: WidgetBundle {
    var body: some Widget {
        GoalWidget()
    }
}

This example combines timeline data, a progress indicator, and a tap target that opens the app. It is small enough to understand, but complete enough to serve as a starting point for a real widget extension.

10. Key Points

  • SwiftUI provides the widget interface, while WidgetKit manages timeline data and system behavior.
  • Widgets should present concise, glanceable information rather than full app workflows.
  • The timeline provider controls what data appears and when the system should refresh it.
  • Different widget families need different layouts, so test each supported size.
  • Shared storage is often required when the widget depends on app data.

11. Practice Exercise

  • Create a widget that shows today’s completed tasks out of a total count.
  • Make it display different text in small and medium widget families.
  • Add a tap action that opens a relevant screen in your app.

Expected output: A widget that shows a short progress summary and opens the app when tapped.

Hint: Keep the entry model small, and use the widget family to simplify the compact layout.

Show solution
Swift
import WidgetKit
import SwiftUI

struct TaskEntry: TimelineEntry {
    let date: Date
    let done: Int
    let total: Int
}

struct TaskProvider: TimelineProvider {
    func placeholder(in context: Context) -> TaskEntry {
        TaskEntry(date: Date(), done: 2, total: 7)
    }

    func getSnapshot(in context: Context, completion: @escaping (TaskEntry) -> Void) {
        completion(TaskEntry(date: Date(), done: 2, total: 7))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<TaskEntry>) -> Void) {
        let entry = TaskEntry(date: Date(), done: 2, total: 7)
        completion(Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(900))))
    }
}

struct TaskWidgetView: View {
    let entry: TaskEntry
    @Environment(\.widgetFamily) var family

    var body: some View {
        Link(destination: URL(string: "myapp://tasks")!) {
            if family == .systemSmall {
                Text("\(entry.done) done")
            } else {
                VStack(alignment: .leading) {
                    Text("Tasks")
                    Text("\(entry.done) of \(entry.total) complete")
                }
            }
        }
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

struct TaskWidget: Widget {
    let kind = "TaskWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: TaskProvider()) { entry in
            TaskWidgetView(entry: entry)
        }
        .configurationDisplayName("Task Progress")
        .description("Shows task progress at a glance.")
    }
}

@main
struct TaskWidgetBundle: WidgetBundle {
    var body: some Widget {
        TaskWidget()
    }
}

The solution shows the main widget pattern in full: timeline provider, family-aware SwiftUI view, configuration, and bundle entry point.

Where AI Coding Assistants Get This Wrong

Patterns AI tools commonly produce for this topic, and what to use instead.

Often suggested: Builds a configurable widget on IntentConfiguration with a SiriKit .intentdefinition file, or passes a plain TimelineProvider to IntentConfiguration.

Instead: On iOS 17+, define a WidgetConfigurationIntent, conform the provider to AppIntentTimelineProvider (async snapshot(for:in:) and timeline(for:in:)), and use AppIntentConfiguration.

The SiriKit path depends on Xcode-generated intent classes and is the legacy route, and IntentConfiguration won't compile with a plain TimelineProvider because it requires IntentTimelineProvider.

Often suggested: Adds Button { model.toggle() }, onAppear, a Timer, or @State to make a widget interactive or self-updating, or claims widgets can't be interactive at all.

Instead: Use Button(intent:) or Toggle(isOn:intent:) with an AppIntent (iOS 17+), and Text(date, style: .timer) for a live countdown.

Widget views are rendered as snapshots in another process, where WidgetKit can't run your closures or bindings. Only App Intents execute.

Often suggested: Leaves out .containerBackground(for: .widget) and paints the background with .background(...) plus .padding().

Instead: Put the background inside .containerBackground(for: .widget) { ... } and rely on the default content margins, or opt out with .contentMarginsDisabled().

On iOS 17+ a missing container background causes Xcode's warning overlay and breaks system background removal, and the extra padding stacks on top of the system margins.

12. Final Summary

SwiftUI for iOS widgets gives you a declarative way to build useful glanceable experiences for the Home Screen and related system surfaces. The key idea is that your widget is not a continuously running view; it is a system-managed presentation powered by a timeline and rendered with SwiftUI.

To build good widgets, keep the content concise, choose the right widget family, share only the data you need, and plan for refresh timing controlled by the system. If you treat widgets as compact status cards instead of miniature app screens, your designs will be easier to read and more reliable in production.

Next, try building a widget from one of your app’s simplest data points, such as a count, a date, or a status summary, and then expand it with timeline updates and family-specific layouts.