Build An iPadOS 18 TabView That Becomes A Customizable Sidebar
A good tab view should feel boring in the best way. People know where the main sections are, each tab keeps its own navigation state, and the app does not need to invent a new top-level navigation model just because the screen got larger.
iPadOS 18 gives SwiftUI a better version of that pattern. The same TabView can show as a tab bar on compact screens and adapt into a sidebar where the platform expects one. On iPad, the .sidebarAdaptable style displays the refreshed top tab bar and lets it become a sidebar. On iPhone, it remains a tab bar. On macOS, it adopts the standard sidebar appearance. On tvOS 18, SwiftUI apps can use the new collapsible sidebar. On visionOS, root tabs appear in an ornament, and TabSection can provide secondary sidebar navigation.
The examples below use the new Tab and TabSection APIs introduced with iOS 18, iPadOS 18, macOS 15, tvOS 18, visionOS 2, and watchOS 11. The .sidebarAdaptable style is not available on watchOS. If your app still supports older releases, keep your existing .tabItem-based TabView as a fallback and gate the new root view with availability checks.
Build the root tabs
Start with the app structure you already want. The new syntax puts the tab label, symbol, optional selection value, and content in one Tab declaration.
import SwiftUI
enum ReaderTab: Hashable {
case today
case library
case downloads
case collection(UUID)
case search
}
struct CollectionTab: Identifiable {
let id: UUID
var name: String
var symbolName: String
}
@available(iOS 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, *)
@available(watchOS, unavailable)
struct ReaderRootView: View {
@State private var selection: ReaderTab = .today
let collections: [CollectionTab]
var body: some View {
TabView(selection: $selection) {
Tab("Today", systemImage: "newspaper", value: .today) {
TodayView()
}
Tab("Library", systemImage: "books.vertical", value: .library) {
LibraryView()
}
TabSection("Collections") {
ForEach(collections) { collection in
Tab(
collection.name,
systemImage: collection.symbolName,
value: ReaderTab.collection(collection.id)
) {
CollectionDetailView(collection: collection)
}
}
}
Tab("Downloads", systemImage: "arrow.down.circle", value: .downloads) {
DownloadsView()
}
Tab(value: .search, role: .search) {
SearchView()
}
}
.tabViewStyle(.sidebarAdaptable)
}
}
The selection value is optional, but it is useful when the app needs to switch tabs after an action. The important rule is that every selectable tab in the TabView(selection:) uses the same selection type. Here that type is ReaderTab.
Tab(role: .search) is a small but useful special case. Search is common enough that SwiftUI has a semantic role for it. When you use the search role, the system can provide the default search title, image, and pinned tab bar placement instead of relying on a custom title and symbol.
Group secondary destinations with TabSection
TabSection is where this API becomes more than a renamed .tabItem. A tab bar should only carry the top-level destinations that are worth constant access. A sidebar can expose more structure.
In the example above, Today, Library, and Downloads are top-level areas. Collections is a group of related destinations. When the interface is a tab bar, SwiftUI still has to keep the tab bar compact. When the interface is a sidebar, the section gives those collection tabs room to breathe.
Apple’s guidance from the session is worth preserving in your app architecture: tabs appear in the order you declare them in the tab bar, while sections are sorted after individual tabs in the sidebar. Put the must-have sections first as individual tabs, then use TabSection for rich groups that make more sense in the sidebar.
You can also add section actions. These are useful for commands that belong to the group instead of to one destination.
TabSection("Collections") {
ForEach(collections) { collection in
Tab(
collection.name,
systemImage: collection.symbolName,
value: ReaderTab.collection(collection.id)
) {
CollectionDetailView(collection: collection)
}
}
}
.sectionActions {
Button("New Collection", systemImage: "plus") {
createCollection()
}
}
On iOS, section actions display after the other tabs in the section. On macOS, they display when the person hovers over the section. That makes them a nice fit for sidebar conveniences, but not for commands that must always be obvious.
Let the user customize it
The adaptable sidebar style can support user customization where the platform exposes customization placements. On iPad, that includes hiding non-essential tabs, reordering sections, and moving items between the sidebar and tab bar. To enable that in SwiftUI, attach a TabViewCustomization binding and give customizable tabs stable IDs.
@available(iOS 18.0, macOS 15.0, visionOS 2.0, *)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
struct CustomizableReaderRootView: View {
@AppStorage("ReaderTabCustomization")
private var customization: TabViewCustomization
@State private var selection: ReaderTab = .today
let collections: [CollectionTab]
var body: some View {
TabView(selection: $selection) {
Tab("Today", systemImage: "newspaper", value: .today) {
TodayView()
}
.customizationBehavior(.disabled, for: .sidebar, .tabBar)
Tab("Library", systemImage: "books.vertical", value: .library) {
LibraryView()
}
.customizationID("com.example.reader.tabs.library")
TabSection("Collections") {
ForEach(collections) { collection in
Tab(
collection.name,
systemImage: collection.symbolName,
value: ReaderTab.collection(collection.id)
) {
CollectionDetailView(collection: collection)
}
.customizationID(
"com.example.reader.tabs.collection.\(collection.id.uuidString)"
)
}
}
.customizationID("com.example.reader.tabs.collections")
.customizationBehavior(.reorderable, for: .sidebar)
Tab("Downloads", systemImage: "arrow.down.circle", value: .downloads) {
DownloadsView()
}
.customizationID("com.example.reader.tabs.downloads")
.defaultVisibility(.hidden, for: .tabBar)
Tab(value: .search, role: .search) {
SearchView()
}
.customizationBehavior(.disabled, for: .sidebar, .tabBar)
}
.tabViewStyle(.sidebarAdaptable)
.tabViewCustomization($customization)
}
}
The IDs are not display strings. Treat them like persistence keys. They should be stable across app launches, app updates, localization changes, and user-edited names. A database ID is a good source for dynamic tabs. An array index is not.
Tabs that participate in customization need a customizationID. If a tab is not customizable in either placement, you can use .customizationBehavior(.disabled, for: .sidebar, .tabBar) and skip the ID. That is a good choice for required destinations like Today, account access, or a search tab that should always be available.
defaultVisibility(_:for:) lets you tune the first-run experience. In this example, Downloads is visible in the sidebar but hidden from the tab bar by default. A person who uses it constantly can add it back where the system allows. A person who never uses it does not have to see it in the compact tab bar.
Choose the initial placement carefully
Most apps should let the system choose the default placement. If your iPad app is deeply sidebar-oriented, you can ask iPadOS to launch the adaptable tab view in its sidebar representation:
TabView(selection: $selection) {
Tab("Today", systemImage: "newspaper", value: .today) {
TodayView()
}
Tab("Library", systemImage: "books.vertical", value: .library) {
LibraryView()
}
}
.tabViewStyle(.sidebarAdaptable)
.defaultAdaptableTabBarPlacement(.sidebar)
defaultAdaptableTabBarPlacement(_:) is an iOS-family modifier, and it is only effective on iPadOS when the tab view is using the sidebarAdaptable style. Other configurations ignore it. I would reach for it when the sidebar is part of the app’s primary workflow, not just because the screen is large.
On supported platforms, you can also add sidebar-only decoration with tabViewSidebarHeader, tabViewSidebarFooter, or tabViewSidebarBottomBar. Keep that content lightweight. The tabs should remain the navigation system, and the header or footer should support it rather than becoming a second dashboard.
A few production habits
This API rewards a clean app hierarchy. If a destination is important everywhere, make it a top-level Tab. If it is part of a group, put it in a TabSection. If it is a command, prefer a toolbar item, menu item, or section action instead of making it a tab.
Use SF Symbols outline variants for tab symbols when you can. The system can choose filled variants for tab bar presentation, while sidebars generally prefer outlined glyphs. That keeps one symbol name working across both forms.
Test the same root view on iPhone, iPad with the sidebar hidden and shown, and macOS if you ship there. A tab title that feels fine in a sidebar can be too long in a compact tab bar. A section that feels useful on iPad may be noise on iPhone if you promote too many children into constant navigation.
Finally, think carefully before disabling customization. Required app areas should stay fixed. Personal or content-specific destinations should usually be movable or hideable. The point of the adaptable sidebar is not just that it becomes wider; it lets the navigation model become more personal without forking the app into separate iPhone, iPad, and Mac structures.
Further reading:
- Apple documentation: https://developer.apple.com/documentation/swiftui/tab
- Apple documentation: https://developer.apple.com/documentation/swiftui/tabsection
- Apple documentation: https://developer.apple.com/documentation/swiftui/tabviewcustomization
- Apple documentation: https://developer.apple.com/documentation/swiftui/view/tabviewcustomization%28_%3A%29
- Apple documentation: https://developer.apple.com/documentation/swiftui/tabviewstyle/sidebaradaptable
- WWDC24: https://developer.apple.com/videos/play/wwdc2024/10147/