visionOS Spatial Patterns Beyond the Window

Most apps that ship on visionOS reach the platform through Apple’s “Designed for iPad” compatibility path: the existing iPad binary runs as a flat panel hovering in 3D space, and the developer ticks a box rather than building a visionOS-native experience. The path is fine for the user (the app works) but it sells the platform short. visionOS’s native surface gives developers three presentation methods (Windows, Volumes, and Immersive Spaces) plus structural UI primitives (Ornaments, Attachments) that the iPad SDK does not have. Apps that adopt them feel native; apps that don’t read as iPad-on-Vision.

The post walks the spatial vocabulary against Apple’s documentation. The frame is “what the platform actually offers a SwiftUI app” rather than a visionOS introduction. The cluster’s RealityKit and the Spatial Mental Model post covers the 3D content layer; this post covers the SwiftUI surface that contains it.

TL;DR

  • visionOS apps compose three scene types: WindowGroup (Windows), WindowGroup with .windowStyle(.volumetric) (Volumes), and ImmersiveSpace (Immersive Spaces)1.
  • A Window is a 2D plane; a Volume is a 3D bounded region; an Immersive Space surrounds the user. Each has different rules: Volumes have immutable post-creation size, Immersive Spaces require explicit open/dismiss, Windows behave most like iPad.
  • Immersion comes in three styles: .mixed (content coexists with the room), .full (room replaced by virtual environment), .progressive (middle ground with peripheral grounding)2.
  • Ornaments are UI planes parallel to a Window and ahead on the z-axis. They’re how visionOS does toolbars and tab bars3. Attachments embed SwiftUI views inside 3D content in a RealityView, the bridge between flat UI and spatial geometry.
  • The “panel app” anti-pattern: shipping the iPad UI as a Window with no Volume, Space, or Ornament adoption. The user can use the app, but the platform’s real value is unclaimed.

The Three Scene Types

A visionOS app’s App body composes scenes from three classes. Each one has a distinct user mental model.

Windows: The 2D Plane

WindowGroup produces a 2D Window with the visionOS glass frame by default. The Window is positioned in space (the system places it in front of where the user is looking) and is moved or resized by the user through standard system gestures. From an SwiftUI standpoint, a Window is the visionOS analogue of a macOS window: a flat content surface with a depth-aware glass material.

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

The default Window has a glass material around its content. Apps that want a fully transparent surface use .windowStyle(.plain):

WindowGroup {
    ContentView()
}
.windowStyle(.plain)

Plain-style Windows lose the system glass frame. Use them when the content provides its own visual container; otherwise the default is correct.

Volumes: The 3D Bounded Region

A Volume is a 3D region that contains depth-aware content (a model, a scene with multiple objects, a UI that benefits from a third axis). The volume scene is also a WindowGroup, with a different style:

WindowGroup(id: "globe") {
    GlobeView()
}
.windowStyle(.volumetric)
.defaultSize(width: 0.6, height: 0.6, depth: 0.6, in: .meters)

The .defaultSize(width:height:depth:in:) modifier specifies the volume’s bounds in real-world units (meters). By default the bounds are fixed at open, and the user can move the volume but not resize it. visionOS 2+ added an opt-in path through .windowResizability(.contentSize) and related APIs for apps that want user-resizable volumes; the fixed-size default remains the most common case. The implication: pick the default size carefully, because most volumes are not resizable unless the developer explicitly opts in.

The right candidates for Volumes are apps where the spatial bound is part of the experience: a virtual sculpture the user walks around, a tape measure pinned to a real wall, a workout scene with depth-staggered targets. Apps that just want a wider canvas don’t gain from a Volume; a larger Window is the right answer.

Immersive Spaces: The Surround

An ImmersiveSpace is a scene that occupies the user’s environment around them. Unlike a Window or Volume (both visible alongside other apps in the Shared Space), an Immersive Space takes over the user’s surroundings and blocks the simultaneous use of other apps’ windows.

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

        ImmersiveSpace(id: "training") {
            TrainingScene()
        }
        .immersionStyle(selection: .constant(.mixed), in: .mixed, .progressive, .full)
    }
}

The .immersionStyle(...) modifier chooses the experience tier:

  • .mixed. Virtual content appears alongside the real room. Used for apps where the user benefits from both contexts.
  • .progressive. A partial immersion that uses the Digital Crown to dial up or down. The user keeps peripheral awareness of the room while the central view is virtual.
  • .full. The room is replaced by a virtual environment. Used for fully immersive experiences (meditation, training simulations, gaming).

Opening an Immersive Space is explicit. The app calls @Environment(\.openImmersiveSpace) with the space’s id; the system handles the transition animation and the dismissal of any conflicting space:

@Environment(\.openImmersiveSpace) var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace

Button("Start Session") {
    Task {
        await openImmersiveSpace(id: "training")
    }
}

Only one Immersive Space can be active at a time per app. The transition between Spaces (from .mixed to .full, for example) requires explicit dismissal of the old Space and opening of the new one.

Ornaments: The UI Planes Around a Window

Ornaments are SwiftUI views attached to a Window’s edge, positioned slightly ahead of the Window plane on the z-axis. They’re how visionOS does toolbars, tab bars, and accessory controls. The system uses ornaments throughout: the playback controls in TV, the segmented control in Music, the toolbar in Mail.

ContentView()
    .ornament(
        attachmentAnchor: .scene(.bottom),
        contentAlignment: .center
    ) {
        HStack {
            Button("Previous", systemImage: "backward.fill") { ... }
            Button("Play", systemImage: "play.fill") { ... }
            Button("Next", systemImage: "forward.fill") { ... }
        }
        .padding()
        .glassBackgroundEffect()
    }

The attachmentAnchor: parameter specifies where the ornament sits relative to the Window: .scene(.top), .scene(.bottom), .scene(.leading), .scene(.trailing). The ornament’s visual treatment is the developer’s responsibility; .glassBackgroundEffect() produces the visionOS-native glass material that matches the Window’s frame.

Ornaments solve a real problem on visionOS: putting controls inside the Window crowds the content; putting them in a separate Window forces the user to re-target their gaze. An ornament hovers in the user’s peripheral vision, gaze-targetable, but doesn’t compete with the main content for the central view.

RealityView Attachments: SwiftUI Inside 3D Space

When an app needs SwiftUI views inside a 3D scene (a label on a 3D model, a button hovering near a virtual object, a measurement readout pinned to a real-world surface), the bridge is RealityView’s attachments mechanism.

RealityView { content, attachments in
    let model = ModelEntity(...)
    content.add(model)

    if let label = attachments.entity(for: "label") {
        label.position = [0, 0.5, 0]
        model.addChild(label)
    }
} attachments: {
    Attachment(id: "label") {
        Text("Vintage Globe, 1872")
            .padding()
            .glassBackgroundEffect()
    }
}

The attachments: closure declares SwiftUI views with stable identifiers. Inside the main RealityView closure, attachments.entity(for:) retrieves the view as a 3D Entity that can be positioned in the scene’s coordinate space. The view participates in SwiftUI’s update cycle (state changes redraw the view) while being rendered as a textured plane in the 3D scene.

The mechanism is the right one for any in-world UI: a label that follows a moving object, a measurement annotation, a contextual button. The SwiftUI view authoring is unchanged; the 3D positioning happens at the RealityView layer.

The “Panel App” Anti-Pattern

The most common visionOS shipping mistake is the panel app: an iPad app that arrives on visionOS through “Designed for iPad” compatibility and ships as a single Window with no Volume, no Immersive Space, and no Ornaments. The app works, but it does not earn the platform.

Three signals that an app is a panel app:

Single Window scene. No .windowStyle(.volumetric), no ImmersiveSpace declared. The app is a flat surface and that’s all.

No ornaments adopted. The app’s tab bar lives inside the Window content rather than outside it. The result is more crowded than a visionOS-native app at the same content density.

No spatial-only features. The app does not use the third axis for anything: no 3D models in a Volume, no environmental scene in a Space, no z-positioned UI through attachments. The app does the same thing it did on iPad, just floating.

Panel apps are not failures; they’re the right move for content categories that don’t benefit from spatial computing (a chat app, a notes app, a settings utility). The failure mode is shipping a panel app and claiming visionOS-native authority for it. The cluster’s Apple Platform Matrix post argues that platform inclusion is a product decision; for visionOS, the decision is “should this app earn the spatial surface, or is the panel adequate?”

Common Failures

Three patterns that produce poor visionOS UX:

Volumes that are actually 2D content with depth padding. A “3D” UI that fills a Volume but renders flat planes inside it produces wasted real estate. Volumes are for 3D content; flat content belongs in a Window.

Immersion style that fights the use case. A meditation app that ships only .full immersion forces the user out of their environment for short sessions. A training app that ships only .mixed doesn’t go far enough for fully focused exercises. Match the immersion style to the user’s actual session.

Ornaments competing with content. Ornaments are peripheral by design. An ornament that demands central attention (a flashing color, animated motion) defeats the purpose. Use ornaments for stable, glance-able controls.

What This Pattern Means For visionOS Apps

Three takeaways.

  1. Pick the scene type by the user’s mental model, not by what’s easy. A flat list of items is a Window. A 3D model the user inspects is a Volume. A surrounding environment is an Immersive Space. Mixing them in one app (a Window with a Volume opened on demand, an Immersive Space accessible from a Window’s button) is the visionOS-native pattern.

  2. Adopt ornaments for toolbars and accessory UI. Ornaments are how visionOS communicates “this UI is supplementary”; putting toolbars inside the Window content reads as iPad-on-Vision. The integration is small and the visual difference is large.

  3. Use attachments for in-world UI in RealityView. Labels on 3D objects, buttons near virtual content, contextual readouts. The bridge between SwiftUI and 3D space is solved; the failure mode is not using it and ending up with ad-hoc 3D text rendering instead.

The full Apple Ecosystem cluster: typed App Intents; MCP servers; the routing question; Foundation Models; the runtime vs tooling LLM distinction; three surfaces; the single source of truth pattern; Two MCP Servers; hooks for Apple development; Live Activities; the watchOS runtime; SwiftUI internals; RealityKit’s spatial mental model; SwiftData schema discipline; Liquid Glass patterns; multi-platform shipping; the platform matrix; Vision framework; Symbol Effects; Core ML inference; Writing Tools API; Swift Testing; Privacy Manifest; Accessibility as platform; SF Pro typography; what I refuse to write about. The hub is at the Apple Ecosystem Series. For broader iOS-with-AI-agents context, see the iOS Agent Development guide.

FAQ

What’s the difference between a Volume and an Immersive Space?

A Volume is a bounded 3D region that lives in the Shared Space alongside other apps. The user can walk around it, the system frames it, and other apps’ Windows remain visible. An Immersive Space surrounds the user, takes over the environment, and prevents the simultaneous use of other apps. Volumes are for “look at this 3D thing”; Spaces are for “be in this environment.”

Can I open multiple Volumes at the same time?

Yes. Multiple WindowGroup-with-.volumetric scenes can be open simultaneously, each with its own size and content. The system positions them independently in space.

Can I open multiple Immersive Spaces at the same time?

No. Only one Immersive Space can be active per app at a time. Switching between Spaces requires explicit dismissal of the current one and opening of the new one through @Environment(\.openImmersiveSpace) and @Environment(\.dismissImmersiveSpace).

Is the Volume size really immutable?

Volume bounds are fixed at open by default; the visionOS HIG framing is that Volumes represent specific 3D content with intentional bounds, and arbitrary user resizing would distort the content’s intended scale. visionOS 2+ added a developer opt-in for resizable volumes via .windowResizability(.contentSize) and related APIs, so apps that need user-resizable spatial containers can request it. Most volumes ship with the fixed default, which the HIG continues to recommend for content with specific scale (a virtual sculpture, a physically-sized model).

How do I add a tab bar to a visionOS Window?

Use a TabView inside the Window for in-content tabs (the iPad-style pattern), or use an ornament with custom button rows for visionOS-native peripheral tab UI. The ornament path is what Apple’s own apps use (Music, Mail) and what feels most native to visionOS users.

Can RealityView attachments interact with hand tracking?

Yes. The attachments are 3D entities once positioned, and they participate in the same gesture and hit-testing system as other RealityKit entities. Tap, drag, and hover gestures attach to them through SwiftUI’s standard gesture modifiers; the cluster’s RealityKit post covers the hand-tracking integration patterns.

References


  1. Apple Developer: Meet SwiftUI for spatial computing (WWDC 2023 session 10109). Introduction of WindowGroup, volumetric WindowGroup, and ImmersiveSpace as the three visionOS scene types. 

  2. Apple Developer Documentation: ImmersionStyle. The three immersion styles (.mixed, .progressive, .full) and the .immersionStyle(selection:in:) modifier API. 

  3. Apple Developer Documentation: ornament(visibility:attachmentAnchor:contentAlignment:ornament:). The SwiftUI view modifier that adds an ornament UI plane to a Window with the specified anchor. 

  4. Apple Developer: Go beyond the window with SwiftUI (WWDC 2023 session 10111). The session covering Volumes, Immersive Spaces, and the patterns for moving beyond flat panel UI on visionOS. 

  5. Apple Developer Documentation: Creating an immersive space in visionOS with SwiftUI. The end-to-end guide to defining and opening immersive spaces. 

Related Posts

RealityKit And The Spatial Mental Model

RealityKit is an entity-component-system, not SwiftUI in 3D. Anchors place entities in real space. Five ways the model d…

16 min read

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 min read

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 min read