```html SwiftUI: How to Implement Lock Screen Widget (iOS 17+, 2026)

How to Implement a Lock Screen Widget in SwiftUI

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

Add a Widget Extension target, declare .accessoryCircular, .accessoryRectangular, or .accessoryInline in supportedFamilies, then switch on @Environment(\.widgetFamily) to render the right view for each lock screen slot.

import WidgetKit
import SwiftUI

struct LockWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "LockWidget", provider: Provider()) { entry in
            LockWidgetView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .configurationDisplayName("My Lock Widget")
        .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
    }
}

struct LockWidgetView: View {
    @Environment(\.widgetFamily) var family
    var entry: SimpleEntry

    var body: some View {
        switch family {
        case .accessoryCircular:  CircularView(entry: entry)
        case .accessoryRectangular: RectangularView(entry: entry)
        default: Text(entry.title)
        }
    }
}

Full implementation

The implementation below lives entirely inside a Widget Extension target — not in your main app. We define a SimpleEntry conforming to TimelineEntry, a Provider that returns a 15-minute refresh timeline, and a main view that branches on widgetFamily to produce the correct layout for each lock screen slot. The key iOS 17 requirement is wrapping every widget view body in .containerBackground(_, for: .widget) — omitting it will cause a runtime warning and incorrect tinting.

import WidgetKit
import SwiftUI

// MARK: - Timeline Entry

struct SimpleEntry: TimelineEntry {
    let date: Date
    let title: String
    let value: Int
    let systemImage: String
}

// MARK: - Timeline Provider

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: .now, title: "Steps", value: 8_420, systemImage: "figure.walk")
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> Void) {
        completion(placeholder(in: context))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) {
        let entry = SimpleEntry(date: .now, title: "Steps", value: 8_420, systemImage: "figure.walk")
        // Refresh every 15 minutes
        let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: .now)!
        completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
    }
}

// MARK: - Sub-views per family

struct CircularView: View {
    let entry: SimpleEntry
    var body: some View {
        ZStack {
            AccessoryWidgetBackground()
            VStack(spacing: 2) {
                Image(systemName: entry.systemImage)
                    .font(.system(size: 16, weight: .semibold))
                Text("\(entry.value / 1000)k")
                    .font(.system(size: 13, weight: .bold, design: .rounded))
            }
        }
        .accessibilityLabel("\(entry.title): \(entry.value)")
    }
}

struct RectangularView: View {
    let entry: SimpleEntry
    var body: some View {
        HStack(spacing: 8) {
            Image(systemName: entry.systemImage)
                .font(.title3.weight(.semibold))
            VStack(alignment: .leading, spacing: 1) {
                Text(entry.title)
                    .font(.caption2.weight(.semibold))
                    .foregroundStyle(.secondary)
                Text("\(entry.value) steps")
                    .font(.subheadline.weight(.bold))
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(entry.title): \(entry.value) steps")
    }
}

// MARK: - Root Widget View

struct LockWidgetView: View {
    @Environment(\.widgetFamily) var family
    let entry: SimpleEntry

    var body: some View {
        Group {
            switch family {
            case .accessoryCircular:
                CircularView(entry: entry)
            case .accessoryRectangular:
                RectangularView(entry: entry)
            case .accessoryInline:
                Label("\(entry.value) steps", systemImage: entry.systemImage)
            default:
                Text(entry.title)
            }
        }
        .containerBackground(.fill.tertiary, for: .widget)
    }
}

// MARK: - Widget Configuration

struct LockScreenWidget: Widget {
    let kind = "LockScreenWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            LockWidgetView(entry: entry)
        }
        .configurationDisplayName("Daily Steps")
        .description("Your step count on the lock screen.")
        .supportedFamilies([
            .accessoryCircular,
            .accessoryRectangular,
            .accessoryInline
        ])
    }
}

// MARK: - Preview

#Preview(as: .accessoryCircular) {
    LockScreenWidget()
} timeline: {
    SimpleEntry(date: .now, title: "Steps", value: 8_420, systemImage: "figure.walk")
    SimpleEntry(date: .now, title: "Steps", value: 10_001, systemImage: "figure.run")
}

How it works

  1. TimelineEntry & ProviderSimpleEntry holds the data snapshot for one point in time. getTimeline returns a single entry and schedules a refresh 15 minutes later via .after(nextUpdate). WidgetKit wakes the extension at that time to fetch new data.
  2. containerBackground — The .containerBackground(.fill.tertiary, for: .widget) modifier applied in LockWidgetView is mandatory on iOS 17+. It sets the system-managed vibrancy layer that adapts to the lock screen wallpaper, letting the OS apply proper tinting for Always-On Display on iPhone 15 Pro and later.
  3. @Environment(\.widgetFamily) branching — Rather than creating three separate widget configurations, one view switches on widgetFamily and delegates to CircularView, RectangularView, or an inline Label. This keeps the entry point (the Widget struct) clean.
  4. AccessoryWidgetBackground — Used inside CircularView, this system-provided view renders the translucent circular capsule that matches the system clock complications, ensuring visual consistency across all wallpapers.
  5. supportedFamilies — Declaring all three accessory families in .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) lets users place the widget in any of the three lock screen slots from the long-press customize sheet.

Variants

App Intent–based interactive widget (iOS 17+)

Lock screen widgets can trigger App Intents directly — useful for a one-tap toggle like a timer start or a focus mode switch. Swap StaticConfiguration for AppIntentConfiguration and wrap tappable elements in Button(intent:).

import AppIntents
import WidgetKit
import SwiftUI

struct StartTimerIntent: AppIntent {
    static var title: LocalizedStringResource = "Start Timer"
    func perform() async throws -> some IntentResult {
        // Trigger your timer logic here
        return .result()
    }
}

struct InteractiveLockWidget: Widget {
    var body: some WidgetConfiguration {
        AppIntentConfiguration(
            kind: "InteractiveLock",
            intent: ConfigurationAppIntent.self,
            provider: Provider()
        ) { entry in
            InteractiveLockView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .supportedFamilies([.accessoryCircular, .accessoryRectangular])
    }
}

struct InteractiveLockView: View {
    let entry: SimpleEntry
    var body: some View {
        Button(intent: StartTimerIntent()) {
            Label("Start", systemImage: "timer")
                .font(.caption.weight(.semibold))
        }
        .buttonStyle(.plain)
        .accessibilityLabel("Start Timer")
    }
}

Always-On Display tinting (Apple Watch / iPhone 15 Pro+)

For AOD support, avoid hard-coded colors — use foreground styles like .foregroundStyle(.primary) and .foregroundStyle(.secondary) instead of Color.white or any fixed hue. The system desaturates and dims widgets on AOD automatically; fixed colors break legibility in that context. Using widgetAccentable() on a view marks it as the accent element that gets the user's chosen tint color.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a lock screen widget in SwiftUI for iOS 17+.
Use WidgetKit with accessoryCircular, accessoryRectangular, and accessoryInline families.
Branch on @Environment(\.widgetFamily) to render the correct layout for each slot.
Apply .containerBackground(.fill.tertiary, for: .widget) to every widget view.
Use AccessoryWidgetBackground for the circular variant.
Make it accessible (VoiceOver labels on all views).
Add a #Preview macro with two timeline entries showing realistic sample data.

Drop this prompt into Soarias during the Build phase after your screen mockups are approved — the generated extension code slots directly into your Xcode project alongside your main app target.

Related

FAQ

Does this work on iOS 16?

Lock screen widgets were introduced in iOS 16 alongside the accessory widget families. However, .containerBackground(_, for: .widget) and the #Preview macro for widgets require iOS 17+ and Xcode 15+. If you need iOS 16 support, guard the modifier with if #available(iOS 17, *) and use PreviewProvider instead of the macro.

Can a lock screen widget open a specific screen in my app?

Yes. Add a Link(destination: URL(string: "myapp://deeplink")!) around the widget view content, or use widgetURL(_:) on the top-level view for a single tap target. The URL is delivered to your app's onOpenURL handler. For interactive button actions that don't navigate (like toggling a value), use Button(intent:) with an AppIntent instead.

What's the UIKit equivalent?

WidgetKit has no UIKit equivalent — widgets are SwiftUI-only by design. The TimelineProvider and configuration structs are framework-level APIs that generate rendered snapshots from SwiftUI view hierarchies; there is no UIViewController path. You can still call UIKit or Foundation code from inside the provider's getTimeline to fetch data — only the view layer must be SwiftUI.

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

```