← All articles

iOS 27 Home Screen Widgets in SwiftUI: A Complete Guide

Build iOS 27 Home Screen widgets in SwiftUI. WidgetKit, TimelineProvider, interactive widgets with App Intents, and the new extra-large portrait family.

iOS 27 Home Screen Widgets in SwiftUI: A Complete Guide

iOS 27 lands on top of a WidgetKit that has been growing for years: iOS 17 made widgets interactive, iOS 18 added Control Center widgets, and this cycle refines the framework with a new extra-large portrait family, smoother state transitions between timeline entries, and tighter memory targets that give a well-behaved widget more headroom before the system throttles it. It is a small release, but it is the one that finally makes widgets feel like first-class app surfaces on iOS.

If you have not shipped a widget yet, that means the landing spot for the framework is unusually generous right now. A widget is a small target with a small SwiftUI view, a timeline, and, since iOS 17, an App Intent that can run the useful action in place. This guide walks the whole surface as it stands on iOS 27: the extension, the widget and its bundle, the timeline, the SwiftUI view, interactive widgets with App Intents, a Control Center widget that reuses the same intent, and what changed this cycle in detail.

On this page

Why widgets earn their place

Two reasons, and neither is decoration.

The first is retention. A user who added your widget to their Home Screen sees your app every time they unlock their phone, whether they open it or not. That single act cuts the churn curve.

The second is a smaller, quieter one. Widgets can now do work in place: toggle a task complete, mark a habit, start a timer. That means your app is not the only path to your feature. Interactive widgets moved widgets from "static snapshot" to "one-tap surface," which is why the framework is worth the couple of extra targets in your project.

Step 1: Add a widget extension

Widgets ship in a separate target that runs alongside your app. In Xcode, choose File → New → Target and pick Widget Extension. Name it something like MyAppWidget. Xcode drops in a starter widget so you can build and run immediately.

Two settings matter on the new target. Make sure it links against the same App Group as your main app if you plan to share data (Signing and Capabilities → App Groups), and if you want App Intents to work, add the Intent to both targets or put shared types in a small framework both can import.

That is the entire project setup. There is no SDK to install; WidgetKit, SwiftUI, and AppIntents ship with the system.

Step 2: Define the widget and its bundle

A widget is a struct that conforms to the Widget protocol, with a body that describes its kind, its configuration, and the view. If you ship more than one, wrap them in a WidgetBundle so the system finds them all.

import WidgetKit
import SwiftUI
 
@main
struct MyAppWidgets: WidgetBundle {
    var body: some Widget {
        StreakWidget()
    }
}
 
struct StreakWidget: Widget {
    let kind = "StreakWidget"
 
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: StreakProvider()) { entry in
            StreakWidgetView(entry: entry)
        }
        .configurationDisplayName("Streak")
        .description("Your current daily streak.")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

StaticConfiguration is the plainest kind: no user configuration on the widget itself. For a widget the user can customize (which account to show, which habit to track), swap it for AppIntentConfiguration and back it with a WidgetConfigurationIntent. Same shape, one extra piece.

Pick your families deliberately. Supporting every size looks generous but often means designing four layouts that never really shine.

Step 3: Build the timeline

WidgetKit does not let you run code on a schedule you dictate. Instead, you hand it a timeline: a list of entries with the times they should be shown, plus a policy for when to ask for the next timeline. The system schedules updates based on load, battery, and how often the user actually looks at the widget.

A TimelineProvider has three methods.

struct StreakEntry: TimelineEntry {
    let date: Date
    let currentStreak: Int
}
 
struct StreakProvider: TimelineProvider {
    // Shown before real data arrives. Keep it fast and safe.
    func placeholder(in context: Context) -> StreakEntry {
        StreakEntry(date: .now, currentStreak: 0)
    }
 
    // Used for the widget gallery and quick previews.
    func getSnapshot(in context: Context, completion: @escaping (StreakEntry) -> Void) {
        completion(StreakEntry(date: .now, currentStreak: readStreak()))
    }
 
    // The real deal: hand back one or more entries and a reload policy.
    func getTimeline(in context: Context, completion: @escaping (Timeline<StreakEntry>) -> Void) {
        let now = Date()
        let today = StreakEntry(date: now, currentStreak: readStreak())
        // Ask for a fresh timeline at the next midnight.
        let midnight = Calendar.current.nextDate(
            after: now, matching: DateComponents(hour: 0), matchingPolicy: .strict
        ) ?? now.addingTimeInterval(3600)
 
        completion(Timeline(entries: [today], policy: .after(midnight)))
    }
 
    private func readStreak() -> Int {
        // Read from an App Group UserDefaults or a shared file the app writes to.
        UserDefaults(suiteName: "group.com.example.myapp")?
            .integer(forKey: "currentStreak") ?? 0
    }
}

Two rules keep timelines predictable. Precompute the entries the widget can display without a network call, and pick a reload policy tied to a meaningful moment (a day boundary, a due date) rather than a fixed interval. .after(midnight) uses fewer refreshes than .after(now + 15min) and looks the same to the user.

Your main app is the source of truth. Write to a shared container (an App Group UserDefaults or a shared file), then call WidgetCenter.shared.reloadTimelines(ofKind: "StreakWidget") when the underlying data changes.

Step 4: The SwiftUI view

A widget view is a normal SwiftUI view with two constraints: it renders once per timeline entry (no @State, no gestures) and it lives inside a containerBackground. The container background is what makes the widget adapt to Home Screen, StandBy, and Lock Screen contexts automatically.

struct StreakWidgetView: View {
    let entry: StreakEntry
 
    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text("Streak")
                .font(.caption)
                .foregroundStyle(.secondary)
            Text("\(entry.currentStreak)")
                .font(.system(size: 42, weight: .bold, design: .rounded))
                .contentTransition(.numericText())
            Text("days")
                .font(.footnote)
                .foregroundStyle(.secondary)
        }
        .padding()
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

containerBackground earns its keep: on the Home Screen it renders the padded background you would expect, on the Lock Screen it strips it to a translucent surface, and on StandBy it fits the always-on style, all from that one call. You do not have to branch on widgetRenderingMode unless you want to.

Step 5: Make it interactive

Since iOS 17, a widget can respond to a tap without launching your app. The pattern is a SwiftUI Button or Toggle bound to an AppIntent. The intent's perform runs in the widget extension, updates your shared data, and the timeline reloads to reflect the result.

Start with the intent.

import AppIntents
import WidgetKit
 
struct MarkDayCompleteIntent: AppIntent {
    static var title: LocalizedStringResource = "Mark Today Complete"
 
    func perform() async throws -> some IntentResult {
        let defaults = UserDefaults(suiteName: "group.com.example.myapp")
        let streak = (defaults?.integer(forKey: "currentStreak") ?? 0) + 1
        defaults?.set(streak, forKey: "currentStreak")
        WidgetCenter.shared.reloadTimelines(ofKind: "StreakWidget")
        return .result()
    }
}

Then wire it into the widget view.

Button(intent: MarkDayCompleteIntent()) {
    Label("Done", systemImage: "checkmark.circle.fill")
}
.buttonStyle(.plain)

That is the whole interaction. Keep the intent's work small and synchronous where possible; the widget extension is a tight environment with strict memory and time budgets, not a place to run heavy async work. For anything longer, kick a background task in your main app and update the widget when it finishes.

Step 6: Ship a Control Center widget

Since iOS 18, third-party controls appear in Control Center, on the Lock Screen, and can be assigned to the Action button. The interesting part is that they use the same App Intent you already wrote. One intent, three surfaces.

Add a control target (Xcode's Widget Extension template covers both) and define a ControlWidget with a button or toggle.

import AppIntents
import WidgetKit
import SwiftUI
 
struct QuickCheckInControl: ControlWidget {
    var body: some ControlWidgetConfiguration {
        StaticControlConfiguration(kind: "QuickCheckIn") {
            ControlWidgetButton(action: MarkDayCompleteIntent()) {
                Label("Check In", systemImage: "checkmark.circle")
            }
        }
        .displayName("Quick Check-In")
        .description("Log today from anywhere.")
    }
}

Add it to your WidgetBundle alongside the Home Screen widget and the system will offer it in Control Center. Users pick where they want it; you write the action once.

What iOS 27 added

WidgetKit at WWDC26 is an evolution, not a rewrite. Every pattern above still works on iOS 17, 18, and 26 as well, but three things are new this cycle and worth knowing when you are designing a widget on iOS 27.

A new extra-large portrait family. systemExtraLargePortrait slots into the top of the size hierarchy on iOS and iPadOS with a tall aspect suited to a dashboard-style layout: a headline metric, a small chart, and a row of quick actions in one glance. Opt in by adding the new family to supportedFamilies and by drawing a layout that uses the vertical space, not a stretched medium widget. On devices that do not support it (older hardware, smaller iPhones), the system falls back to the largest available size automatically, so a single switch on the widget family covers both.

Smoother state transitions between entries. When the widget's timeline reloads, iOS 27 animates the change instead of hard-cutting. A counter that ticks, a task that completes, or a streak that lands a new day now reads as motion. You do not opt in; SwiftUI content transitions on the values that change (a contentTransition(.numericText()) on a number, for example) are honoured across timeline reloads instead of being reset. For most widgets, the practical effect is that a good widget feels alive without extra code.

Tighter memory and power budgets, with more headroom for the well-behaved. The framework's runtime guidance was refined so widgets that stay in budget get more consistent scheduling, and widgets that overshoot get throttled faster and more visibly in Instruments. The rules that matter have not changed: precompute your timeline entries, avoid heavy work in the view, keep intents small, and let your main app do the network. A well-built widget on iOS 26 stays well-built on iOS 27 and gets a little more room to run.

For the wider WWDC26 toolchain, see the Xcode 27 guide and what's new in Swift.

FAQ

Do widgets share code with my main app?

Yes. Put shared models, App Intents, and any small helpers in a framework both targets import, and share data through an App Group container. The widget extension is a separate target, but nothing stops it from using the same Swift code.

How often do widgets update?

You do not schedule updates directly. You hand WidgetKit a timeline plus a reload policy, and the system decides when to run it based on load, battery, and user attention. Design your timeline around meaningful moments, not fixed intervals.

Can a widget make a network call?

It can, but keep it small and rare. The widget extension has tight time and memory budgets, and long or frequent network calls get throttled. The safer pattern is to let your main app write to shared storage and have the widget read from it.

What is the difference between a widget and a Control Center control?

A widget shows data and can respond to a tap. A control is a one-tap surface only, but it appears in Control Center, on the Lock Screen, and can be assigned to the Action button. Both are wired to App Intents, so you can share the action between them.

Can widgets be interactive without launching the app?

Yes, since iOS 17. A Button or Toggle bound to an AppIntent runs its perform in the widget extension and the timeline reloads to reflect the result. The app does not open.

Do widgets work on Lock Screen and StandBy?

Yes. Use containerBackground for the widget and the system adapts its rendering across Home Screen, Lock Screen, and StandBy contexts. You can branch on widgetRenderingMode if you need bespoke styles per surface.

A widget is a small target with a small timeline, a small view, and, since iOS 17, a small App Intent that does the useful thing. The reward for shipping one is real, though: an app that keeps showing up when your user unlocks their phone, and, with a Control Center widget, an action they can take from anywhere on the system.


Spaceport generates a production-ready SwiftUI Xcode project with a Home Screen widget already wired up, alongside RevenueCat subscriptions, Sign in with Apple and Google, notifications, an AI assistant, onboarding, and App Store Connect pricing across 25 markets. The widget extension, the shared App Group, and the timeline plumbing above ship working in the generated project, so you start from a widget that runs on the first build. From an indie iOS dev, for indie iOS devs.

Read more at spaceport.build

Community appsJoin Discord