How to Implement a Lock Screen Widget in SwiftUI
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
-
TimelineEntry & Provider —
SimpleEntryholds the data snapshot for one point in time.getTimelinereturns a single entry and schedules a refresh 15 minutes later via.after(nextUpdate). WidgetKit wakes the extension at that time to fetch new data. -
containerBackground — The
.containerBackground(.fill.tertiary, for: .widget)modifier applied inLockWidgetViewis 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. -
@Environment(\.widgetFamily) branching — Rather than creating three separate widget configurations, one view switches on
widgetFamilyand delegates toCircularView,RectangularView, or an inlineLabel. This keeps the entry point (theWidgetstruct) clean. -
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. -
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
-
Missing
.containerBackgroundon iOS 17+. Forgetting this modifier produces a Xcode runtime warning ("Widget view body does not use containerBackground") and the OS renders a black background instead of the adaptive vibrancy layer. Every lock screen widget view must call it — even if you just pass.clear. -
Confusing
.systemSmallwith accessory families. Lock screen widgets use.accessoryCircular,.accessoryRectangular, and.accessoryInline— not the home screen size classes. Declaring.systemSmallwill not make your widget appear in the lock screen customize sheet. -
Hard-coded colors break AOD and dark wallpapers. Lock screen widgets inherit a vibrancy rendering mode. Use semantic foreground styles and avoid
Color(hex:)values — they appear washed out or invisible depending on wallpaper brightness and AOD state. Stick to.primary,.secondary, andwidgetAccentable(). -
Refresh budget exhaustion. WidgetKit enforces a daily refresh budget (roughly 40–70 reloads). Requesting a timeline refresh every minute on the lock screen depletes it quickly, causing stale data. Prefer 15–30 minute intervals and rely on
WidgetCenter.shared.reloadTimelines(ofKind:)from the main app after a meaningful data change.
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.