SwiftUI NavigationSplitView: The Complete Guide to Adaptive Multi-Column Navigation on iPhone, iPad, and Mac
NavigationSplitView is SwiftUI's adaptive sidebar container. Learn selection bindings, deep linking, iPhone collapse behaviour, and accessibility patterns for iOS 26.
NavigationSplitView is the SwiftUI container that adapts a two- or three-column sidebar layout to whatever device you run on (full-width columns on iPad and Mac, a stacked drill-down on iPhone) without you writing a single size-class check. You declare the columns, bind selection, and SwiftUI handles the rest: pop-overs in Slide Over, sidebar toggles in the toolbar, and even VoiceOver focus restoration when a user rotates the device.
NavigationSplitView renders two or three resizable columns on iPad/Mac and automatically collapses to a stack on iPhone. No manual horizontalSizeClass branching required.
Bind columnVisibility to a NavigationSplitViewVisibility state to control sidebar appearance; use .preferredCompactColumn to choose which column shows first on iPhone.
Always pair the detail column with its own NavigationStack so push navigation from a row works on every form factor.
Three-column views (sidebar + content + detail) require a stable selection model. Use two @State bindings, never one nested optional.
VoiceOver places initial focus on the sidebar by default; restore focus to the detail column after selection with AccessibilityFocusState.
On iOS 26 the toolbar .toolbarRole(.editor) swaps the back button for column controls, fixing the long-standing "Back" label spillover bug.
What is NavigationSplitView in SwiftUI?
NavigationSplitView is a SwiftUI container introduced in iOS 16 that arranges its children into a sidebar-driven, multi-column layout that responds to the host platform's size class. On iPad in landscape and on Mac you get the familiar sidebar/content/detail trio you'd expect from Mail or Notes; on iPhone the same view collapses into a navigation stack, drilling from sidebar to content to detail in sequence. Honestly, in my experience, replacing a custom NavigationView-plus-HSplitView hybrid with a single NavigationSplitView deletes more code than almost any other 2026-era SwiftUI migration I've shipped.
The canonical signature takes two trailing closures (sidebar and detail), or three for a content column in the middle:
struct LibraryView: View {
@State private var selectedFolder: Folder?
@State private var selectedNote: Note?
var body: some View {
NavigationSplitView {
FolderList(selection: $selectedFolder)
} content: {
NoteList(folder: selectedFolder, selection: $selectedNote)
} detail: {
NoteEditor(note: selectedNote)
}
}
}
Notice there are no NavigationLinks tying the columns together. Selection flows through bindings. That single change is what lets the same view adapt across iPhone, iPad, Mac Catalyst, and visionOS without rewrites. The container also publishes NavigationSplitViewVisibility values so you can persist column state to @SceneStorage and restore the user's layout after a relaunch.
Two-column vs three-column layouts
Choose two columns when your content has a single hierarchy depth: settings panels, a chat sidebar with active conversation, or a file inspector. Choose three columns when there is a real intermediate list a user needs to scan: folders → messages → reading pane is the classic. The decision matters because SwiftUI's collapse behaviour differs. A two-column split collapses to one push (sidebar pushes detail); a three-column split collapses to two pushes (sidebar pushes content, content pushes detail).
So, here's a three-column example with proper selection plumbing. Note that both selection bindings live at the parent level, not nested inside each other:
struct MailView: View {
@State private var mailbox: Mailbox? = .inbox
@State private var message: Message.ID?
@State private var visibility: NavigationSplitViewVisibility = .all
var body: some View {
NavigationSplitView(columnVisibility: $visibility) {
MailboxList(selection: $mailbox)
.navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 320)
} content: {
if let mailbox {
MessageList(mailbox: mailbox, selection: $message)
.navigationSplitViewColumnWidth(min: 280, ideal: 360)
} else {
ContentUnavailableView("Select a mailbox", systemImage: "tray")
}
} detail: {
NavigationStack {
if let message {
MessageDetail(id: message)
} else {
ContentUnavailableView("Select a message", systemImage: "envelope.open")
}
}
}
}
}
A few things I'd flag from production code I've shipped. First, navigationSplitViewColumnWidth takes either a fixed value or a min/ideal/max. Use the latter so users on a Studio Display don't end up with a 180-point sidebar lost on a 27-inch screen. Second, fall back to ContentUnavailableView rather than an empty EmptyView(): on Mac and visionOS the detail column is always visible, and a blank pane reads as broken.
Controlling column visibility programmatically
The columnVisibility binding accepts five NavigationSplitViewVisibility values: .automatic, .all, .doubleColumn, .detailOnly, and (iOS 17.1+) .threeColumn. Most apps want .automatic on launch and use the binding only for explicit user actions (say, a "Hide Sidebar" toolbar button) or for restoring layout from @SceneStorage:
@SceneStorage("library.visibility") private var rawVisibility: String = "automatic"
@State private var visibility: NavigationSplitViewVisibility = .automatic
var body: some View {
NavigationSplitView(columnVisibility: $visibility) { /* ... */ }
.onAppear { visibility = NavigationSplitViewVisibility(raw: rawVisibility) }
.onChange(of: visibility) { _, new in rawVisibility = new.rawValueString }
}
For the second column in three-column setups, animate visibility changes with a quick .snappy spring. The default linear animation looks cheap next to other 2026 SwiftUI transitions. If you've spent time tuning SwiftUI spring animations, the same response/dampingFraction values translate directly here.
How does NavigationSplitView work on iPhone?
On iPhone (and any compact horizontal size class), SwiftUI silently rewrites your NavigationSplitView into a NavigationStack rooted at the sidebar. Tapping a row pushes the next column, and on a three-column split you get a two-step drill-down. This is usually what you want, but if your sidebar is just one "All Items" row, that extra push feels wrong. Set preferredCompactColumn to skip ahead:
With .detail, iPhone launches straight into the detail view; the sidebar slides in via the back button. Use .sidebar when sidebar-first is the genuine entry point. The binding is two-way: if the user backs out to the sidebar, compactColumn updates so you can reflect the navigation state in analytics or programmatic deep links.
One gotcha worth shouting about: a three-column NavigationSplitView on iPhone does not respect preferredCompactColumn = .content reliably. If your middle column is what users care about, restructure as a two-column split where the sidebar is the "content" list. I burned half a day debugging this exact mismatch on a launch project. Don't.
Programmatic selection and deep linking
Because each column drives off a binding, deep links are trivial. Push a notification payload through your app router and assign to the selection state. SwiftUI animates the columns into place:
@MainActor
final class Router: ObservableObject {
@Published var selectedFolder: Folder?
@Published var selectedNote: Note?
func open(noteID: Note.ID, in folderID: Folder.ID) async {
guard let folder = await Folder.fetch(folderID) else { return }
selectedFolder = folder
selectedNote = await Note.fetch(noteID)
}
}
For Universal Links, do the same dance from onContinueUserActivity. If you've followed the patterns from type-safe routing with NavigationStack, you already have the navigation model, and NavigationSplitView just plugs into the same selection state with no extra plumbing. Be aware that selection changes are not implicitly animated; wrap the assignment in withAnimation if you want a smooth column transition.
Embedding NavigationStack inside the detail column
Push navigation from the detail column (for example, drilling from a note into a linked attachment) needs its own NavigationStack. The split view doesn't provide one for the detail closure on purpose, because Mac and visionOS sidebars stay visible during pushes. Wrap the detail content yourself:
detail: {
NavigationStack(path: $detailPath) {
NoteEditor(note: selectedNote)
.navigationDestination(for: Attachment.self) { att in
AttachmentView(attachment: att)
}
.navigationDestination(for: TagFilter.self) { filter in
TagFilteredList(filter: filter)
}
}
}
Bind the path with @State at the parent so you can reset it when the user switches sidebar items. A common bug: switching folders should pop the detail stack to root, but without an explicit detailPath = [] on selection change, your user is left looking at an attachment view that no longer belongs to the visible note.
NavigationSplitView vs NavigationStack: which should you use?
If your app has a sidebar conceptually (folders, mailboxes, categories, channels), use NavigationSplitView. If it's a pure drill-down (Settings, an onboarding flow, a checkout funnel), use NavigationStack. The two compose, not compete: most production apps use a split view at the root and embed stacks inside one or more columns. Here's the side-by-side I refer teammates to:
Dimension
NavigationSplitView
NavigationStack
Mental model
Selection-driven columns
Push/pop path
iPad/Mac layout
2 or 3 visible columns
Full-width stack
iPhone behaviour
Auto-collapses to stack
Stack (native)
State driver
@State selection bindings
NavigationPath
Deep linking
Set selection bindings
Append to path
Sidebar toolbar
Built-in toggle
None
Best for
Mail-style, document apps
Settings, drill-downs, modals
Min iOS
16.0
16.0
For the official semantics, the Apple Developer documentation for NavigationSplitView is worth reading end-to-end at least once. The Human Interface Guidelines page on Apple's sidebar guidance covers when a sidebar earns its place in your UI. Short answer: when you have parallel collections users meaningfully switch between.
Accessibility, VoiceOver, and Dynamic Type
This is where most NavigationSplitView implementations quietly fail. VoiceOver focuses the sidebar first by default, which is correct, but after a user selects a row, focus doesn't move to the detail column automatically. Sighted users see the change; VoiceOver users hear nothing. Fix it with AccessibilityFocusState:
struct MessageDetail: View {
let id: Message.ID
@AccessibilityFocusState private var titleFocused: Bool
var body: some View {
ScrollView {
Text(message.subject)
.font(.title2)
.accessibilityFocused($titleFocused)
// ...
}
.onChange(of: id) { _, _ in titleFocused = true }
}
}
Dynamic Type also exposes a sidebar pitfall: at .accessibility3 and up, fixed column widths clip row labels. Let labels reflow with .lineLimit(nil) and prefer the min/ideal/max overload of navigationSplitViewColumnWidth so the sidebar can grow. For the full accessibility checklist I run on every NavigationSplitView-based app, see the SwiftUI accessibility guide. It covers focus order, VoiceOver rotor customization, and Dynamic Type breakpoints in depth.
Common pitfalls and how to fix them
I've reviewed enough NavigationSplitView code to recognise the recurring mistakes by sight. Here are the ones worth flagging:
Selection of Optional<Model> rather than ID. Causes the detail column to over-render on any model field mutation. Bind Model.ID and resolve inside the column.
Forgetting the detail NavigationStack. Push navigation silently no-ops. If NavigationLink taps do nothing in the detail column, you're missing the stack.
Forcing columnVisibility = .all on iPhone. Renders an overlay sidebar on top of detail content. Let the framework collapse.
Stale detail state on sidebar change. Reset the detail NavigationPath in onChange(of: selectedFolder).
Hard-coded column widths in points. Breaks on Studio Displays and at large Dynamic Type. Use the min/ideal/max overload.
Ignoring toolbarRole(.editor) on iOS 26. The new role swaps "Back" for proper column-toggle affordances. See the SwiftUI ToolbarRole reference for the matrix of behaviours.
Animating selection without withAnimation. Column transitions are instant by default. Wrap programmatic assignments in withAnimation(.snappy) for a polished feel.
Combine these with a habit of running every change through Voice Control's "Show numbers" view and you'll catch 90% of issues before review.
Frequently Asked Questions
When should I use NavigationSplitView instead of NavigationStack?
Use NavigationSplitView when your app has a sidebar-style hierarchy (mailboxes, folders, categories) that users switch between rather than drill through. Use NavigationStack for pure drill-down flows like Settings or onboarding. They compose: most real apps wrap a stack inside the split view's detail column.
Why is my NavigationSplitView sidebar overlapping the detail view on iPhone?
You've likely set columnVisibility to .all at the binding level. On compact width, that forces the sidebar to overlay rather than collapse. Use .automatic as the default and let the framework collapse to a stack. Control compact behaviour with preferredCompactColumn instead.
How do I make a three-column NavigationSplitView open the detail view first on iPhone?
Set preferredCompactColumn to .detail. Note that this is unreliable for three-column splits, since Apple's adaptive logic still routes through the sidebar in some cases. If reliable detail-first launch matters, restructure as two columns where the sidebar acts as the content list.
Can I animate column visibility changes in NavigationSplitView?
Yes. Wrap the columnVisibility assignment in withAnimation. A .snappy spring matches the system feel; the default linear interpolation looks cheap next to iOS 26 transitions. The same applies to programmatic selection changes when you want columns to slide in rather than swap.
What is the minimum iOS version for NavigationSplitView?
NavigationSplitView requires iOS 16.0, iPadOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, or visionOS 1.0. The preferredCompactColumn binding and three-column NavigationSplitViewVisibility cases require iOS 17.1+. For pre-iOS 16 fallbacks, use the deprecated NavigationView with .navigationViewStyle(.columns).
Build production-ready custom SwiftUI layouts in iOS 26. Walk through ProposedViewSize, layout caches, LayoutValueKey, and animating between layouts with AnyLayout.
A practical guide to SwiftUI DocumentGroup in iOS 26: FileDocument vs ReferenceFileDocument, custom UTType setup, FileWrapper for packages, autosave and undo wiring, plus the platform quirks on iPhone, iPad, Mac, and visionOS that nobody warns you about.
Master SwiftUI ScrollView in iOS 26 with scrollPosition, scrollTransition, scrollTargetBehavior, contentMargins, paging, and Liquid Glass edge effects, all with runnable Xcode 26 code.