How to Build iPad Split View in SwiftUI
Use NavigationSplitView to get a native iPad split-view layout — it automatically collapses to a stack on iPhone and adapts to Stage Manager and Slide Over without extra code.
struct ContentView: View {
@State private var selectedItem: Item?
var body: some View {
NavigationSplitView {
List(Item.samples, selection: $selectedItem) { item in
Text(item.title).tag(item)
}
.navigationTitle("Items")
} detail: {
if let item = selectedItem {
DetailView(item: item)
} else {
Text("Select an item")
.foregroundStyle(.secondary)
}
}
}
}
Full implementation
The example below builds a two-column split view — sidebar on the left, detail on the right — backed by an @Observable model. A NavigationSplitViewVisibility binding lets users and the app itself toggle the sidebar programmatically. Column widths are pinned with navigationSplitViewColumnWidth() so the sidebar never grows too wide on large iPads.
import SwiftUI
// MARK: - Model
struct Item: Identifiable, Hashable {
let id = UUID()
var title: String
var body: String
var systemImage: String
static let samples: [Item] = [
Item(title: "Inbox", body: "All unread messages live here.", systemImage: "tray"),
Item(title: "Starred", body: "Your favourited items appear here.", systemImage: "star"),
Item(title: "Archive", body: "Archived content is stored here.", systemImage: "archivebox"),
Item(title: "Settings", body: "App preferences and account info.", systemImage: "gear"),
]
}
// MARK: - Root
struct ContentView: View {
@State private var columnVisibility: NavigationSplitViewVisibility = .all
@State private var selectedItem: Item?
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
// Sidebar column
List(Item.samples, selection: $selectedItem) { item in
Label(item.title, systemImage: item.systemImage)
.tag(item)
}
.navigationTitle("Library")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
columnVisibility = columnVisibility == .all
? .detailOnly : .all
} label: {
Label("Toggle Sidebar",
systemImage: "sidebar.left")
}
}
}
// Pin the sidebar to a comfortable fixed width
.navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 320)
} detail: {
if let item = selectedItem {
DetailView(item: item)
} else {
ContentUnavailableView(
"Nothing Selected",
systemImage: "sidebar.left",
description: Text("Pick an item from the sidebar.")
)
}
}
// Prefer a side-by-side split rather than an overlay on iPad
.navigationSplitViewStyle(.balanced)
}
}
// MARK: - Detail
struct DetailView: View {
let item: Item
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Label(item.title, systemImage: item.systemImage)
.font(.largeTitle.bold())
Divider()
Text(item.body)
.font(.body)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
}
.navigationTitle(item.title)
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Preview
#Preview {
ContentView()
}
How it works
-
NavigationSplitView initializer — The two-closure form (
sidebar:+detail:) gives you the classic mail-style layout. Pass the optionalcolumnVisibility:binding if you need programmatic control; omit it for the system default. -
List selection binding —
List(Item.samples, selection: $selectedItem)wires row taps directly to the state that drives the detail column. WhenselectedItembecomes non-nil the detail redraws automatically. -
navigationSplitViewColumnWidth() — Called on the sidebar
List, this modifier gives the system a min/ideal/max range. The sidebar stays readable on a 13-inch iPad Pro without stretching awkwardly. -
navigationSplitViewStyle(.balanced) — Applied to the root split view, this style gives both columns proportional space. Use
.prominentDetailif the detail should dominate, or.automaticto let the system decide per size class. - ContentUnavailableView — The iOS 17 API replaces the old "empty state" pattern with a built-in component that handles layout, tinting, and Dark Mode for you when no item is selected.
Variants
Three-column layout (sidebar + content + detail)
Add a third closure for a Mail-style "mailbox → message list → reading pane" hierarchy. Each column gets its own selection binding.
struct ThreeColumnView: View {
@State private var selectedFolder: Folder?
@State private var selectedMessage: Message?
var body: some View {
NavigationSplitView {
FolderList(selection: $selectedFolder)
.navigationTitle("Folders")
} content: {
if let folder = selectedFolder {
MessageList(folder: folder,
selection: $selectedMessage)
.navigationTitle(folder.name)
} else {
Text("Choose a folder").foregroundStyle(.secondary)
}
} detail: {
if let message = selectedMessage {
MessageDetail(message: message)
} else {
ContentUnavailableView("No Message",
systemImage: "envelope")
}
}
.navigationSplitViewStyle(.balanced)
}
}
Hiding the sidebar by default on compact size classes
On iPhone the split view collapses to a NavigationStack automatically — you do not need an explicit @Environment(\.horizontalSizeClass) guard. If you want to start with the sidebar hidden on iPad too, initialise the visibility binding as @State private var columnVisibility: NavigationSplitViewVisibility = .detailOnly and the system will respect it on first launch.
Common pitfalls
-
Targeting iOS 16 or earlier:
NavigationSplitViewships from iOS 16, butContentUnavailableViewand the@Observablemacro require iOS 17. Gate those features with#available(iOS 17, *)if you support iOS 16 at all — otherwise set your deployment target to iOS 17 and keep the code clean. -
Using NavigationLink inside the sidebar instead of List selection: Mixing
NavigationLinkwith theselection:binding breaks two-column routing — the detail column is driven by the selection state, not by push navigation. Stick toList(selection:)and.tag(). -
Forgetting VoiceOver labels on the sidebar toggle button: The toolbar button above uses
Label("Toggle Sidebar", systemImage:), which automatically supplies both a visible icon and an accessible label. Using a bareImage(systemName:)without a label leaves the button unlabelled for VoiceOver users. -
Animating columnVisibility changes: Wrapping the toggle in a
withAnimationblock is ignored — column transitions are controlled by the system. Attempting to animate them manually produces visual glitches, especially in Stage Manager.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement iPad split view in SwiftUI for iOS 17+. Use NavigationSplitView with a two-column sidebar + detail layout. Bind List(selection:) to an @State optional model item. Apply navigationSplitViewColumnWidth(min:ideal:max:) to the sidebar. Show ContentUnavailableView when nothing is selected. Make it accessible (VoiceOver labels on toolbar buttons). Add a #Preview with realistic sample data.
In the Soarias Build phase, paste this prompt directly into Claude Code alongside your SwiftData model so the generated split view is immediately wired to your app's real data layer.
Related
FAQ
Does this work on iOS 16?
NavigationSplitView itself is available from iOS 16, but this page's full implementation relies on ContentUnavailableView and the @Observable macro, both of which require iOS 17. If you must support iOS 16, replace ContentUnavailableView with a plain Text view and use ObservableObject instead of @Observable.
How do I deep-link into a specific detail view on launch?
Pre-populate the selection state before the view appears. If you receive a URL or a push notification payload, resolve it to an Item in your App entry point and pass a binding down, or set the value inside .onOpenURL. SwiftUI will route to the correct detail column as soon as the binding has a value.
What is the UIKit equivalent?
UISplitViewController with style: .doubleColumn or .tripleColumn is the UIKit counterpart. NavigationSplitView wraps this behaviour and adds declarative state-driven routing, automatic iPhone collapse, and Stage Manager awareness — use SwiftUI unless you are maintaining an existing UIKit codebase.
Last reviewed: 2026-05-11 by the Soarias team.