Control Widgets in iOS 26: Build Custom Control Center Toggles with ControlWidget and SwiftUI
Build Control Center toggles, buttons, and value displays in iOS 26 with ControlWidget and SwiftUI. Full code, AppIntent integration, and Xcode 26 debugging tips.
Control Widgets in iOS 26 are SwiftUI-powered toggles, buttons, and value pickers that live in Control Center, on the Lock Screen, and on the Action Button. You build them by declaring a ControlWidget inside a Widget Extension and backing each control with an AppIntent. Introduced in iOS 18 and expanded in iOS 26, Control Widgets let your app expose one or two privileged actions (start a timer, flip a smart light, jump into a capture flow) without forcing users to open the app. This guide walks through exactly how to build, test, and ship them.
Control Widgets ship inside the same Widget Extension you already use for Home Screen widgets. You add a ControlWidget conforming type alongside your existing Widget types.
iOS 26 supports three kinds: ControlWidgetButton for one-shot actions, ControlWidgetToggle for on/off state, and value-bearing variants that show a live readout in Control Center.
Every control is driven by an AppIntent. Buttons run a PerformIntent, toggles use a SetValueIntent, and value displays poll a ControlValueProvider.
Users add your control by tapping the Customize icon in Control Center. Once added, it can also be assigned to the Action Button or the Lock Screen via Settings.
Control Widgets are size-constrained (one SF Symbol, a short label, one optional accent color), so design for glanceability rather than richness.
Debugging requires running the Widget Extension target with the Widget scheme in Xcode 26 and using the Control Center simulator overlay introduced in iOS 26.
What are Control Widgets in iOS 26?
A Control Widget is a compact SwiftUI surface, no larger than a single Control Center slot, that performs one privileged action on tap or hold. Apple introduced the API in iOS 18 to open Control Center to third-party developers, and iOS 26 added richer value displays, per-control accent tints, and Lock Screen placement. Each control is declared as a type conforming to ControlWidget. The protocol is intentionally minimal: a kind string identifier, plus a body that returns one of the supported control configurations.
Unlike Home Screen widgets, Control Widgets don't render arbitrary SwiftUI hierarchies. The system enforces a fixed template (an SF Symbol, an optional short label, and an optional value readout), so every third-party control looks consistent with Apple's own controls like Flashlight, Camera, and Wi-Fi. The action behind the control is always an AppIntent, which means the same code that powers a control can also power a Siri shortcut, an interactive widget, or an automation. If you've already built App Intents for widgets and Siri shortcuts, you can promote one of them to a Control Widget in roughly twenty lines of code.
Apple covers the full surface in the WidgetKit Controls documentation. This guide focuses on what changed in iOS 26 and the gotchas that aren't in the docs.
Setting up a Widget Extension for Control Widgets
Control Widgets live inside a Widget Extension target, the same one you use for Home Screen widgets and Live Activities. If your app already has one, you don't need a new target. Just add a Swift file declaring your ControlWidget type and register it in the WidgetBundle. If you're starting fresh, add a new target via File > New > Target > Widget Extension in Xcode 26 and uncheck Include Live Activity unless you need one.
The minimum deployment target for Control Widgets is iOS 18.0, but several iOS 26 features (value providers with refresh policies, Lock Screen placement, accent tints) require an iOS 26.0 runtime check. Your WidgetBundle looks like this:
import WidgetKit
import SwiftUI
@main
struct MyAppWidgetBundle: WidgetBundle {
var body: some Widget {
// Existing Home Screen widgets
DailySummaryWidget()
// New: Control Widgets
QuickCaptureControl()
FocusToggleControl()
UnreadCountControl()
}
}
One trap to avoid: Control Widgets do not support per-instance configuration through IntentConfiguration the way Home Screen widgets do. If you need different copies of the same control (one toggle per smart bulb, say), you have to register one ControlWidget type per logical variant, or rely on a ControlValueProvider that scopes itself via an external identifier the user picks at customization time. Honestly, this is the single biggest difference from configurable Home Screen widgets, and it trips up nearly every developer their first day. I hit it shipping a smart-home control last fall and lost a good half-day before re-reading the protocol docs.
Building your first ControlWidgetButton
A ControlWidgetButton is the simplest control: tap it, an AppIntent runs, the system animates feedback, done. Here's a working example that opens a quick-capture flow when tapped. Drop this file into your Widget Extension target:
import AppIntents
import SwiftUI
import WidgetKit
// 1. Declare the intent that does the work.
struct StartQuickCaptureIntent: AppIntent {
static let title: LocalizedStringResource = "Start Quick Capture"
static let openAppWhenRun: Bool = true
func perform() async throws -> some IntentResult & OpensIntent {
// The system opens the app and routes to the capture scene.
return .result(opensIntent: OpenCaptureSceneIntent())
}
}
// 2. Declare the Control Widget itself.
struct QuickCaptureControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: "com.example.MyApp.QuickCaptureControl"
) {
ControlWidgetButton(action: StartQuickCaptureIntent()) {
Label("Quick Capture", systemImage: "camera.aperture")
}
.tint(.orange)
}
.displayName("Quick Capture")
.description("Open the app's quick capture sheet.")
}
}
Three things to notice. First, kind must be a globally unique reverse-DNS string, because the system uses it as the persistent identity if the user moves the control around. Second, openAppWhenRun plus OpensIntent is the canonical way to bounce the user into your app from a control. If you set openAppWhenRun = false, the work runs in your Widget Extension's process, which is sandboxed and limited to a few seconds. Third, .tint(.orange) only applies in iOS 26+; on iOS 18 it's silently ignored, which is fine for backward compatibility.
Creating a stateful ControlWidgetToggle
Toggles are where Control Widgets get interesting. A ControlWidgetToggle displays a boolean state (think Focus On / Focus Off) and flips it when tapped, without launching your app. The state is owned by your app, surfaced through a ControlValueProvider, and mutated by a SetValueIntent:
import AppIntents
import SwiftUI
import WidgetKit
// 1. The intent flips the boolean.
struct ToggleFocusIntent: SetValueIntent {
static let title: LocalizedStringResource = "Toggle Focus"
@Parameter(title: "Focus Enabled")
var value: Bool
func perform() async throws -> some IntentResult {
await FocusStore.shared.setEnabled(value)
return .result()
}
}
// 2. The value provider reads current state.
struct FocusValueProvider: ControlValueProvider {
func currentValue() async throws -> Bool {
await FocusStore.shared.isEnabled
}
var previewValue: Bool { false }
}
// 3. The Control Widget binds the two together.
struct FocusToggleControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: "com.example.MyApp.FocusToggle",
provider: FocusValueProvider()
) { isEnabled in
ControlWidgetToggle(
"Focus",
isOn: isEnabled,
action: ToggleFocusIntent()
) { isEnabled in
Label(
isEnabled ? "Focus On" : "Focus Off",
systemImage: isEnabled ? "moon.fill" : "moon"
)
}
}
.displayName("Focus Mode")
.description("Toggle focus mode on or off.")
}
}
The reason this design feels heavy is that the system needs to render the control before your app launches. previewValue is what shows in the customization picker. currentValue() runs in the Widget Extension process, so keep it under 100ms and never touch the network. Store the truth in App Group UserDefaults or a shared SQLite file so both the app and the extension can read it without IPC. The SetValueIntent protocol is a special variant of AppIntent that the system uses specifically for two-state controls; its value parameter is what the system sets to the desired post-tap state before invoking perform().
Showing live data with ControlValueProvider
iOS 26 expanded ControlValueProvider to surface arbitrary Codable values, not just Bool. That unlocks controls like "Unread count: 12" or "Timer: 04:32" without bouncing into the app. The pattern looks like this:
struct UnreadCountValueProvider: ControlValueProvider {
typealias Value = Int
func currentValue() async throws -> Int {
let defaults = UserDefaults(suiteName: "group.com.example.MyApp")
return defaults?.integer(forKey: "unreadCount") ?? 0
}
var previewValue: Int { 3 }
}
struct UnreadCountControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(
kind: "com.example.MyApp.UnreadCount",
provider: UnreadCountValueProvider()
) { count in
ControlWidgetButton(action: OpenInboxIntent()) {
Label("\(count) unread", systemImage: "tray.full")
}
}
.displayName("Unread Mail")
.description("Show unread count and open inbox.")
}
}
The system refreshes the value opportunistically: when the user opens Control Center, when your app calls ControlCenter.shared.reloadControls(ofKind:), or on a system-determined cadence. There is no fixed timeline, so treat updates as best-effort and design copy that still makes sense if it's a few seconds stale. For coordination patterns between widgets and your main app, see our guide on building interactive SwiftUI widgets with WidgetKit.
How AppIntents power every Control Widget
Every Control Widget action is an AppIntent. This isn't optional. There is no action: () -> Void closure parameter anywhere in the API. The reason is architectural: the system needs to invoke the action from any process (Control Center is its own UI process, separate from both your app and your Widget Extension) and persist the invocation across reboots. AppIntent is the only currency that satisfies both constraints.
So there's a happy side effect here: the same intent that powers your control can also power Siri ("Hey Siri, start quick capture"), Shortcuts automations, and interactive Home Screen widgets. If you've structured your app around App Intents already, for example following our App Intents from first principles walkthrough, adding a Control Widget is a twenty-line addition, not a refactor. The AppIntents framework reference is the authoritative source for parameter types, error handling, and donation patterns.
One subtlety: intents that mutate user data should set static let isDiscoverable = true so they surface in Spotlight and Shortcuts. Transient or implementation-detail intents should set it to false to avoid cluttering the user's automation surface. Intents used purely as Control Widget actions can go either way. Discoverability is a UX choice, not a technical requirement.
How do users add Control Widgets to Control Center?
Users add a Control Widget by entering Control Center customization mode and picking from the gallery. The flow on iOS 26 is: swipe down from the top-right corner to open Control Center, tap the + button in the top-left, tap Add a Control, scroll through the alphabetically grouped gallery, then tap the control to insert it. The gallery groups controls by the app they belong to. Your displayName from the ControlWidget body is the headline, and the description is the supporting line.
Once added to Control Center, the same control can be assigned to two additional surfaces. The Action Button on iPhone 15 Pro and later sits at Settings > Action Button > Controls > Choose a Control. The Lock Screen: long-press the Lock Screen, tap Customize > Lock Screen, tap one of the two control slots, pick your control. Both surfaces use the same ControlWidget code, so you don't write anything extra. Users cannot, however, place Control Widgets on the Home Screen; that surface is reserved for traditional widgets. If you want a tappable Home Screen tile that runs the same intent, build a small Home Screen widget that uses the same AppIntent as its Button action.
Debugging and testing Control Widgets in Xcode 26
Debugging Control Widgets is more awkward than Home Screen widgets, because Control Center runs in a separate UI process. So here's the workflow that actually works in Xcode 26:
In Xcode's scheme picker, select your Widget Extension target (not the app).
Set the executable to Ask on Launch in the scheme's Run settings, then choose Control Center when prompted.
Build and run. The Simulator opens with Control Center pre-loaded and your control visible.
Tap the control to fire the intent. Breakpoints in perform() hit inside the extension process.
For value-provider debugging, the iOS 26 Simulator added a Debug > Reload Control Values menu item that calls reloadControls(ofKind:) for every registered kind. It's useful when you've changed App Group state and want to see the control refresh without restarting. On-device, you can use the Console app filtered to subsystem:com.apple.WidgetKit-Extension to catch errors from your value provider; thrown errors there fall back to previewValue rather than crashing the control. Pair this with the techniques in our SwiftUI performance optimization guide if your control's value provider is the bottleneck.
Control Widget design best practices
The constraints (one symbol, one short label, one tint) are deliberate. The best Control Widgets follow three rules. One privileged action per control. If you're tempted to write "Open Inbox or Compose," split it into two controls and let the user pick. State must be unambiguous at a glance. A toggle should change its symbol when flipped (filled vs. outlined) so users can read state without focusing on text. Tints reinforce, they don't decorate. Use a single semantic tint per control: red for destructive, green for on, your app's accent color for everything else. Reserve color for state that matters.
Avoid the temptation to register five Control Widgets just because you can. Apple's own apps ship one or two controls per app. The customization picker gets noisy fast, and users delete apps that pollute it. Pick the one action a user reaches for from the Lock Screen at 7am and ship that. If you do need more than two, group them around clear themes (capture, playback, smart-home) so the gallery entries read as a coherent family rather than a grab bag.
Frequently Asked Questions
What is the difference between a Control Widget and a Home Screen widget?
Home Screen widgets render arbitrary SwiftUI views in fixed sizes and live on the Home Screen. Control Widgets render a single symbol-plus-label template, live in Control Center, the Lock Screen, or the Action Button, and always fire an AppIntent on tap. They're built in the same Widget Extension but conform to different protocols (ControlWidget vs. Widget).
Can a Control Widget run code without opening the app?
Yes. If the backing AppIntent sets openAppWhenRun = false, the perform() method runs inside your Widget Extension process with no app launch. You get a few seconds of wall time, no UI, and a sandboxed environment. Use this for quick state changes, and bounce into the app for anything involving a user-facing flow.
Why isn't my Control Widget appearing in the Control Center gallery?
The three most common causes: (1) you forgot to add the new ControlWidget to your WidgetBundle's body; (2) the deployment target of the Widget Extension is below iOS 18; (3) the device hasn't reindexed yet, and fully quitting and relaunching the Settings app, or rebooting the device, usually fixes it. Verify by running the Widget Extension scheme directly in Xcode.
Do Control Widgets work on iPad and macOS?
iPadOS 18 added Control Widget support to iPad's Control Center. macOS does not currently host Control Widgets, since there's no Control Center equivalent surface on the Mac. visionOS does not support them either. Your code compiles for those platforms but the controls won't appear; gate registration with #if os(iOS) if you need to be tidy.
How do I update a Control Widget's value from my main app?
Call ControlCenter.shared.reloadControls(ofKind: "your.kind.string") from your app after the underlying data changes. The system will re-invoke your ControlValueProvider.currentValue() the next time Control Center is opened. Without that call, the control may show stale data for an arbitrary period.
Daniel is a former Spotify iOS engineer (2019-2024) who worked on the Now Playing surface and the in-app podcast player. He shipped the SwiftUI rewrite of the lyrics view to over 600 million users and contributed several fixes upstream to swift-collections.
His writing tends toward the unglamorous corners of iOS work: build-time regressions in Xcode 16, why SwiftData still isn't ready for production sync scenarios, and how to instrument a real app with os_signpost without drowning in noise. He spent two years before Spotify at a fintech startup in Berlin building a banking app on top of Solaris API.
Daniel now freelances out of Lisbon and maintains a small open-source library for type-safe deep links in SwiftUI. He has 9 years of native iOS experience.
A hands-on walkthrough of the Xcode 26 SwiftUI preview crash on certain @Observable types, what the crash log really means, three workarounds that work today, and the bug report shape Apple triages fastest.
Why iOS 26's .glassEffect() modifier silently fails in real SwiftUI hierarchies, the four preconditions Apple's docs gloss over, and the exact GlassEffectContainer fix I used in production.
Apple shipped the Liquid Glass design language with iOS 26, and SwiftUI exposes most of it through a single new modifier: .glassEffect(_:in:). The API surface is tiny. The behavior is not.