Building Widgets for visionOS
Let’s recap what we’ve learned about Widgets and answer some questions from the community.
Find these and many more examples on the Learn visionOS page.
- Getting started with visionOS Widgets
- Adding content and options for configuration
- Simple Interactions
- Updating widgets using timelines
- Reloading a widget from an app scene
- Adapting content to rendering modes
Concepts
Adding widgets to a project is a simple process. Xcode provides a template to get us started. The building blocks we need to learn include:
- AppIntent: App Intents that can define customization options and perform actions in our app.
- A WidgetBundle where we can use one or more widgets.
- AppIntentTimelineProvider creates the timeline refresh policy and generates an array of TimelineEntry
- A SwiftUI View where we can define the content of our widget.
- And finally, the actual Widget. This is a configuration object where we can define the features that a widget supports and the view it should render.
There are a handful of features that make widgets special on Apple Vision Pro. Let’s take a quick tour of these.
We can use supportedFamilies to define an array of sizes for a widget. Not all sizes are supported on visionOS. As of visionOS Beta 5 we can use: [.systemSmall, .systemMedium, .systemExtraLargePortrait].
struct SimpleWidgets: Widget {
let kind: String = "SimpleWidgets"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
SimpleWidgetsEntryView(entry: entry)
.containerBackground(.white.gradient, for: .widget)
}
.supportedFamilies([.systemSmall, .systemMedium, .systemExtraLargePortrait])
}
}We can use .supportedMountingStyles to support .elevated (default) or .recessed (new) mounting styles.
struct SimpleWidgets: Widget {
let kind: String = "SimpleWidgets"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
SimpleWidgetsEntryView(entry: entry)
.containerBackground(.white.gradient, for: .widget)
}
.supportedFamilies([.systemSmall, .systemMedium, .systemExtraLargePortrait])
.supportedMountingStyles([.elevated, .recessed])
}
}We can use widgetTexture to select a texture that suits our design. Currently we can choose from .glass and .paper. The paper variant looks really good when viewed on device.
struct SimpleWidgets: Widget {
let kind: String = "SimpleWidgets"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
SimpleWidgetsEntryView(entry: entry)
.containerBackground(.white.gradient, for: .widget)
}
.supportedFamilies([.systemSmall, .systemMedium, .systemExtraLargePortrait])
.supportedMountingStyles([.elevated, .recessed])
.widgetTexture(.paper) // or .glass
}
}We can use LevelOfDetail to provide more than one view for a widget. For example, a full version with interactive buttons and a simple version with visual content.
struct SimpleWidgetsEntryView : View {
var entry: Provider.Entry
@Environment(\.levelOfDetail) var levelOfDetail: LevelOfDetail
var body: some View {
switch levelOfDetail {
case .simplified:
VStack {
Text(entry.date, style: .time)
Text(entry.configuration.favoriteEmoji)
}
default:
VStack {
Text("Time:")
Text(entry.date, style: .time)
Text("Favorite Emoji:")
Text(entry.configuration.favoriteEmoji)
Button(intent: SomeIntent()) {
// perform the action defined in SomeIntent when this is tapped
}
}
}
}
}When can use widgetRenderingMode to determine how to render content.
struct RenderWidgetsEntryView : View {
@Environment(\.widgetRenderingMode) var renderingMode
var body: some View {
if renderingMode == .fullColor {
// Provide a main version of the widget content
} else if renderingMode == .accented {
// Provide an alternative view in accented mode
}
}
}Accented mode will render all visual content into two groups: default and accented. We can opt views into the accented group if needed.
VStack {
Text("Default Group")
Text("Accent Group")
.widgetAccentable(true)
}When a Widget includes images, we can specify how the user-selected tint value is applied using widgetAccentedRenderingMode.
Image(.jsWidget)
.widgetAccentedRenderingMode(.accentedDesaturated)
We can define the background of a widget as removable by using containerBackground. visionOS will determine when to remove this background view.
EmojiWidgetEntryView(entry: entry)
.containerBackground(.white.gradient, for: .widget)We can also make sure visionOS never removes the background of a widget using .containerBackgroundRemovable(false)
AppIntentConfiguration(kind: kind, intent: EmojiConfigurationAppIntent.self, provider: EmojiProvider()) { entry in
EmojiWidgetEntryView(entry: entry)
// .containerBackground(.white.gradient, for: .widget)
}
.containerBackgroundRemovable(false)
Configuration & Interaction
Users can tap and hold on a widget to show a menu where they can select from system options. We can define custom options for our widgets that will appear in this menu. We do this by using App Intents.
This will create a text field where a user can type an emoji. Note, this doesn’t actually restrict them to emoji only.
struct ConfigurationAppIntent: WidgetConfigurationIntent {
...
@Parameter(title: "Emoji", default: "🌸")
var emoji: String
}We can provide value pickers too. We’ll make a type.
enum DisplayOption: String, AppEnum {
case live = "Live"
case laugh = "Laugh"
case love = "Love"
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Title Options"
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
.live: DisplayRepresentation(title: "Live"),
.laugh: DisplayRepresentation(title: "Laugh"),
.love: DisplayRepresentation(title: "Love")
]
}Then use it in an App Intent
struct ConfigurationAppIntent: WidgetConfigurationIntent {
...
@Parameter(title: "Title", default: .live)
var display: DisplayOption
...
}
What if we want to provide a button or control in the widget itself? Again, we use App Intents. We can make an intent that will increment a value and store it in User Defaults.
struct IncrementCountIntent: AppIntent {
static var title: LocalizedStringResource = "Increment Count"
static var description: IntentDescription = "Increase the emoji count by 1"
func perform() async throws -> some IntentResult {
// Get current count from UserDefaults and increment
let currentCount = UserDefaults.standard.integer(forKey: "EmojiWidgetCount")
let newCount = min(currentCount + 1, 12)
UserDefaults.standard.set(newCount, forKey: "EmojiWidgetCount")
// Reload the widget timeline
WidgetCenter.shared.reloadTimelines(ofKind: "EmojiWidget")
return .result()
}
}In our view, we can call this via a special version of Button
Button(intent: IncrementCountIntent()) {
Image(systemName: "plus.circle.fill")
}Notice in the IncrementCountIntent we include a line to reload the timelines for this widget. This will tell visionOS to re-render the widget immediately while generating new timeline entries. When this happens we have a brief time to animate visual changes. When we put all these concepts together, we get a widget and looks and feels interactive.
Updating Widgets
Widgets are updated by two main mechanisms.
- Timelines: we define a timeline reload policy and a means to generate timeline entries. visionOS will reload entries based on this timeline. These reloads can be budgeted by the system.
- Snapshot: visionOS will determine when it need to update a widget. This may happened after putting on the headset of exiting an immersive space.
We can adjust the how we generate the timeline entries. For this widget we’ll generate 60 entries at 1 second intervals.
struct ClockProvider: AppIntentTimelineProvider {
...
func timeline(for configuration: ClockConfigurationAppIntent, in context: Context) async -> Timeline<ClockEntry> {
var entries: [ClockEntry] = []
// Generate a timeline with entries every second for the next minute
let currentDate = Date()
for secondOffset in 0 ..< 60 {
let entryDate = Calendar.current.date(byAdding: .second, value: secondOffset, to: currentDate)!
let entry = ClockEntry(date: entryDate, configuration: configuration)
entries.append(entry)
}
return Timeline(entries: entries, policy: .atEnd)
}
...
}WidgetKit provides a way to reload these timelines, triggering the widget to reload. We can do this for all widgets or for a single widget.
// Reload all timelines for all widgets
WidgetCenter.shared.reloadAllTimelines()
// Reload timeline for a widget
WidgetCenter.shared.reloadTimelines(ofKind: "MoodWidgets")In the Button example above, we called WidgetCenter.shared.reloadTimelines from within an App Intent to reload a widget. We can also call this from within an App. For example, data changes in the app, we can use this to trigger a reload, causing the widget to perform its internal data fetching to update content.
private func saveEmojiToUserDefaults(_ emoji: String) {
sharedUserDefaults.set(emoji, forKey: "dataEmojiExample")
WidgetCenter.shared.reloadTimelines(ofKind: "MoodWidgets")
}There is one more notable way we can update a widget. We can send notifications to trigger updates. This is useful for apps that exist on multiple devices. For example, if we updated data in a macOS app, we could send a notification to cause the widget in visionOS to update. We haven’t had a chance to dive into this feature yet, but you can learn more from the documentation.
Questions from the Community
The first thing that a lot of wanted to know: can we use RealityKit in widgets? Unfortunately no. When we tried, RealityView simply didn’t show up. We also tried using Model3D to no avail.
SwiftUI finally got a WebView this year. Can we use it in widgets? We tried a quick test of a simple WebView loading a URL. It didn’t render and the console showed a flood of “unsupported” type warnings.
Can we animate widgets? Sort of. The official way to animate widgets is to reload them using the timeline reload policy. We can animate states between versions of widget renders, but actually using SwiftUI animation code doesn’t work well. This clock widget animates the second hand when WidgetKit renders based on the one-second timeline reload policy. Unfortunately, it is imprecise in unreliable.
There is a well known workaround to keep a widget animating on iOS. This involves using a timer view in the widget to keep content animating over time. This worked for us in a quick test, but it doesn’t seem like an official use case.
When should we use widgets instead of Windows? Obviously, visionOS Windows provide much more control. We can use them to create fully interactive interfaces that update based on state. In visionOS 26 users can snap and lock windows in place.
One notable difference is that widgets hide when entering a system environment. Snapped windows do not. This can often break the illusion of the environment when we can see windows that were snapped to a wall in passthrough floating in the middle of nowhere in the environment.
Use Windows when you need full control of data, state, and animations. Use widgets to provide ambient views and simple controls that can perform one-off actions in an app.
This concludes our brief tour of Widgets on visionOS 26. You can dive into each of the examples in this series to learn more. Please let us know if there is a topic we should add to this series or a question we haven’t answered.

Follow Step Into Vision