```html SwiftUI: Stage Manager Support (iOS 17+, 2026)

How to implement Stage Manager support in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: WindowGroup Updated: May 11, 2026
TL;DR

Declare UIApplicationSupportsMultipleScenes = true in Info.plist, define your scenes with WindowGroup in your App struct, and use the openWindow environment action to open new windows. Pair with adaptive layouts so your UI reflows gracefully as Stage Manager windows are resized.

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .windowResizability(.contentMinSize)

        WindowGroup("Detail", id: "detail", for: Item.ID.self) { $id in
            DetailView(itemID: id)
        }
        .windowResizability(.contentMinSize)
    }
}

// Open a second window from any view:
struct ContentView: View {
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        Button("Open Detail") {
            openWindow(id: "detail", value: someItem.id)
        }
    }
}

Full implementation

The complete solution wires together three pieces: the multi-scene Info.plist key, a well-structured App struct with typed WindowGroup scenes, and an adaptive content view that reflows as the Stage Manager window grows or shrinks. The containerRelativeFrame modifier (iOS 17+) lets each column or tile respond to its container size rather than the full screen, making it the ideal primitive for Stage Manager layouts.

// ── MyApp.swift ──────────────────────────────────────────────
import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        // Primary window — shown on launch and in Stage Manager
        WindowGroup {
            ContentView()
        }
        .windowResizability(.contentMinSize)   // honour SwiftUI minimum

        // Secondary typed window opened on demand
        WindowGroup(
            "Item Detail",
            id: "item-detail",
            for: Item.ID.self
        ) { $itemID in
            if let id = itemID {
                ItemDetailView(itemID: id)
            } else {
                ContentUnavailableView("No item", systemImage: "tray")
            }
        }
        .windowResizability(.contentMinSize)
        .defaultSize(width: 420, height: 600)
    }
}

// ── Item model ────────────────────────────────────────────────
struct Item: Identifiable, Hashable {
    let id: UUID
    var title: String
}

// ── ContentView.swift ─────────────────────────────────────────
import SwiftUI

struct ContentView: View {
    @Environment(\.openWindow) private var openWindow
    @Environment(\.horizontalSizeClass) private var hSizeClass

    private let items: [Item] = (1...12).map {
        Item(id: UUID(), title: "Item \($0)")
    }

    // Adapt column count: 1 in compact, 2-3 in regular / larger Stage Manager window
    private var columns: [GridItem] {
        let count = hSizeClass == .compact ? 1 : 3
        return Array(repeating: GridItem(.flexible(), spacing: 12), count: count)
    }

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: columns, spacing: 12) {
                    ForEach(items) { item in
                        ItemCard(item: item) {
                            openWindow(id: "item-detail", value: item.id)
                        }
                    }
                }
                .padding()
            }
            .navigationTitle("My App")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("New Window", systemImage: "rectangle.stack.badge.plus") {
                        openWindow(id: "item-detail", value: items.first?.id)
                    }
                }
            }
        }
    }
}

// ── ItemCard.swift ─────────────────────────────────────────────
struct ItemCard: View {
    let item: Item
    let onOpen: () -> Void

    var body: some View {
        RoundedRectangle(cornerRadius: 16, style: .continuous)
            .fill(Color(.secondarySystemBackground))
            .overlay(
                VStack(alignment: .leading, spacing: 8) {
                    Text(item.title)
                        .font(.headline)
                    Button("Open in Window", action: onOpen)
                        .font(.footnote)
                        .buttonStyle(.bordered)
                }
                .padding()
                , alignment: .topLeading
            )
            .containerRelativeFrame(.horizontal, count: 1, spacing: 0)
            .frame(height: 120)
            .accessibilityElement(children: .combine)
            .accessibilityLabel(item.title)
            .accessibilityAddTraits(.isButton)
    }
}

// ── ItemDetailView.swift ───────────────────────────────────────
struct ItemDetailView: View {
    let itemID: Item.ID

    var body: some View {
        NavigationStack {
            Text("Detail for \(itemID)")
                .navigationTitle("Detail")
                .navigationBarTitleDisplayMode(.inline)
        }
    }
}

// ── Preview ────────────────────────────────────────────────────
#Preview("Compact") {
    ContentView()
        .environment(\.horizontalSizeClass, .compact)
}

#Preview("Regular") {
    ContentView()
        .environment(\.horizontalSizeClass, .regular)
}

How it works

  1. Multi-scene entitlement: Setting UIApplicationSupportsMultipleScenes = true in Info.plist tells iPadOS the app can host more than one scene simultaneously. Without this key Stage Manager will still show your app, but only ever in a single window — openWindow silently does nothing.
  2. Typed WindowGroup: The secondary WindowGroup("Item Detail", id: "item-detail", for: Item.ID.self) declaration registers a scene that can be opened with a strongly typed value. iPadOS deduplicates by value — opening the same Item.ID twice brings the existing window to the front rather than creating a duplicate.
  3. .windowResizability(.contentMinSize): This modifier tells Stage Manager to respect the minimum intrinsic size your SwiftUI hierarchy reports, preventing the window from being squashed below a size your layout can't handle.
  4. Adaptive grid with horizontalSizeClass: @Environment(\.horizontalSizeClass) dynamically reports .compact or .regular as the Stage Manager window is resized. The grid column count responds instantly — no manual geometry tracking required.
  5. containerRelativeFrame: Applied to each card, this modifier (iOS 17+) sizes children relative to their scroll-view container rather than the full screen, so cards fill the Stage Manager window width correctly at any size without hard-coded constants.

Variants

Detect when your scene is active vs. backgrounded

struct ContentView: View {
    @Environment(\.scenePhase) private var scenePhase

    var body: some View {
        Text("Hello, Stage Manager")
            .onChange(of: scenePhase) { _, newPhase in
                switch newPhase {
                case .active:
                    // Window moved into focus — resume timers, refresh data
                    print("Scene active")
                case .inactive:
                    // Window is visible but not focused (e.g., behind another window)
                    print("Scene inactive")
                case .background:
                    // Window is hidden or app moved to background
                    print("Scene in background")
                @unknown default:
                    break
                }
            }
    }
}

Set a default window size and position

Use .defaultSize and .defaultPosition on your WindowGroup to control where and how large a new scene appears when first opened. Both are hints — Stage Manager respects the user's subsequent repositioning and stores it per-scene.

WindowGroup("Settings", id: "settings") {
    SettingsView()
}
.windowResizability(.contentSize)     // lock to content size
.defaultSize(width: 360, height: 520)
.defaultPosition(.center)

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement stage manager support in SwiftUI for iOS 17+.
Use WindowGroup with typed IDs, openWindow, windowResizability,
containerRelativeFrame, and horizontalSizeClass for adaptive layouts.
Enable UIApplicationSupportsMultipleScenes in Info.plist.
Make it accessible (VoiceOver labels on all window-opening buttons).
Add a #Preview with realistic sample data for both compact and regular size classes.

Paste this prompt into Soarias during the Build phase after your screens are scaffolded — Claude Code will wire up the scene declarations, adaptive grid, and multi-window plumbing in one pass, leaving you to review the diff and ship.

Related

FAQ

Does Stage Manager support work on iOS 16?

Stage Manager itself requires iPadOS 16 on M1 iPad Pro/Air or iPadOS 17 on all supported iPads. However, WindowGroup multi-scene support is available from iPadOS 14+. You can safely target iOS 16 as your deployment minimum — Stage Manager will activate automatically when the device and OS support it, while older devices get a standard single-window experience.

How do I prevent duplicate windows for the same item?

Use typed WindowGroup with a for: parameter and pass a stable Hashable identifier (like a UUID or a database row ID) to openWindow(id:value:). iPadOS automatically de-duplicates: if a scene with that exact value is already open, Stage Manager raises it to the front instead of creating a new one.

What's the UIKit equivalent?

In UIKit you'd implement UISceneDelegate + UIWindowSceneDelegate, configure UISceneConfiguration in Info.plist, and request new scenes via UIApplication.shared.requestSceneSessionActivation(_:userActivity:options:). SwiftUI's WindowGroup + openWindow is the idiomatic replacement and handles scene lifecycle bookkeeping automatically.

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

```