SwiftUI TabView in iOS 26: Tab API, Sidebar Adaptation, and the Floating Tab Bar

How to build SwiftUI TabView in iOS 26 with the new Tab struct, TabSection sidebars, search role, and the floating tab bar. Migration patterns from the old selection-based API and the accessibility checks I run before every ship.

SwiftUI TabView iOS 26: Tab API & Sidebar Guide

Updated: June 20, 2026

SwiftUI TabView in iOS 26 is the declarative tab container you build with Tab values instead of index-based selection: bindings, and it adapts automatically between a tab bar on iPhone, a floating tab bar with sidebar on iPad, and a NavigationSplitView-style sidebar on Mac and visionOS. The new Tab struct, TabSection grouping, TabRole.search, and .tabViewStyle(.sidebarAdaptable) together replace nearly every workaround we used to ship. This guide covers the API, the migration path from older code, and the accessibility behaviour I always wire in before tagging a build.

  • The Tab struct in iOS 18+ (refined in iOS 26) replaces tag-based selection with typed values, eliminating a whole class of off-by-one bugs.
  • .tabViewStyle(.sidebarAdaptable) renders as a tab bar on iPhone and a sidebar on iPad/Mac/visionOS, so there's no separate NavigationSplitView code path required.
  • TabSection groups tabs in the sidebar with optional headers, and users can reorder and hide them when you opt in with .tabViewCustomization.
  • Tab(role: .search) renders a system-styled search tab that gets the floating accessory treatment on iPad and a magnifying-glass affordance on iPhone.
  • .tabBarMinimizeBehavior(.onScrollDown) is the iOS 26 way to hide the bar during reading, replacing custom safeAreaInset hacks.
  • VoiceOver, keyboard navigation, and Dynamic Type all work out of the box, but only if you give each Tab a real Label with non-empty text and a unique value.

What changed in the Tab API for iOS 26

iOS 18 introduced the Tab struct as the modern way to build a TabView, and iOS 26 promotes it from "nice to have" to "this is what the platform expects". The old pattern of applying .tabItem to a child view and tagging it with an Int still compiles, but you lose every new affordance: sidebar adaptation, customisation persistence, the floating accessory, the minimise-on-scroll behaviour, and the search role. The new initialiser takes a value, a title, a system image, and a closure that returns the destination view, which makes the relationship between a tab and its identifier explicit.

Two things to internalise before writing any code. First, the value on each Tab is now a typed Hashable (usually an enum case), so the compiler catches the mismatch between "tab existed yesterday" and "selection saved in @AppStorage still points at it". Second, the styling decision is no longer "tab bar or sidebar"; it's .sidebarAdaptable, and SwiftUI decides per-platform and per-size-class. On iPad in landscape you get the sidebar, on iPhone you get the bar, and on Mac and visionOS you almost always get the sidebar. You don't write that branching logic anymore, and trying to override it usually ends up fighting the system. If you've already built your stack-based navigation flow, my SwiftUI NavigationStack guide pairs naturally with this article. Most production apps put a NavigationStack inside each Tab.

Building a basic TabView with the Tab struct

So, let's start small. The smallest useful TabView in iOS 26 looks like this. I use an enum for the tab identifier because the compiler will then refuse to let you typo a tab id or leave a stale case in @AppStorage.

import SwiftUI

enum AppTab: String, Hashable, CaseIterable {
    case home, library, profile
}

struct RootView: View {
    @AppStorage("selectedTab") private var selection: AppTab = .home

    var body: some View {
        TabView(selection: $selection) {
            Tab("Home", systemImage: "house", value: AppTab.home) {
                NavigationStack { HomeView() }
            }
            Tab("Library", systemImage: "books.vertical", value: AppTab.library) {
                NavigationStack { LibraryView() }
            }
            Tab("Profile", systemImage: "person.circle", value: AppTab.profile) {
                NavigationStack { ProfileView() }
            }
        }
    }
}

A few things to notice. The selection binding is optional. If you omit it, SwiftUI manages selection internally and you lose persistence across launches, which is almost never what you want. Each Tab wraps its destination in a NavigationStack, because TabView is not itself a navigation container; tabs hold independent navigation paths. And the system image strings are the same SF Symbols you would pass to Image(systemName:). Pick variants that have a .fill counterpart so the selected state animates cleanly.

Add .tabViewStyle(.sidebarAdaptable) to the TabView and group related tabs in a TabSection. SwiftUI then chooses the layout: tab bar on iPhone, floating tab bar plus collapsible sidebar on iPad, persistent sidebar on Mac and visionOS. You don't need an explicit NavigationSplitView for this; the sidebar is implied by the style.

TabView(selection: $selection) {
    Tab("Home", systemImage: "house", value: AppTab.home) {
        NavigationStack { HomeView() }
    }

    TabSection("Library") {
        Tab("Reading", systemImage: "book", value: AppTab.reading) {
            NavigationStack { ReadingView() }
        }
        Tab("Listening", systemImage: "headphones", value: AppTab.listening) {
            NavigationStack { ListeningView() }
        }
        Tab("Watching", systemImage: "play.rectangle", value: AppTab.watching) {
            NavigationStack { WatchingView() }
        }
    }

    Tab("Profile", systemImage: "person.circle", value: AppTab.profile) {
        NavigationStack { ProfileView() }
    }
}
.tabViewStyle(.sidebarAdaptable)

On iPhone, TabSection is ignored visually; the section's tabs are flattened into the bar. On iPad and Mac, the section header appears in the sidebar above its grouped rows. The iPhone-only floating tab bar that ships in iOS 26 still respects sections when the user expands the bar with a long press, so don't over-stuff a section. Four or five tabs per section is the practical limit before scrolling becomes painful. If you've already invested in column-based navigation, my NavigationSplitView guide covers when to reach for the explicit split instead of the adaptable tab style. The rule of thumb is that NavigationSplitView wins when your hierarchy is deep and your sections are content-driven, while sidebar-adaptable TabView wins for shallow, fixed-section apps.

How do I customize the TabView in SwiftUI?

The iOS 26 customisation system lets users drag tabs around, hide them, and persist that layout across launches. You opt in with .tabViewCustomization and a @AppStorage-backed TabViewCustomization value. The framework writes a small binary blob to user defaults, and you don't parse it yourself.

struct RootView: View {
    @AppStorage("selectedTab") private var selection: AppTab = .home
    @AppStorage("tabCustomization") private var customization = TabViewCustomization()

    var body: some View {
        TabView(selection: $selection) {
            Tab("Home", systemImage: "house", value: AppTab.home) {
                NavigationStack { HomeView() }
            }
            .customizationID("tab.home")

            TabSection("Library") {
                Tab("Reading", systemImage: "book", value: AppTab.reading) {
                    NavigationStack { ReadingView() }
                }
                .customizationID("tab.reading")

                Tab("Listening", systemImage: "headphones", value: AppTab.listening) {
                    NavigationStack { ListeningView() }
                }
                .customizationID("tab.listening")
                .defaultVisibility(.hidden, for: .tabBar) // hidden on iPhone bar by default
            }
            .customizationID("section.library")
        }
        .tabViewStyle(.sidebarAdaptable)
        .tabViewCustomization($customization)
    }
}

Three rules I've learned the hard way. First, every tab and section that participates in customisation needs a stable customizationID; if you regenerate IDs between launches the persisted layout silently resets. Second, .defaultVisibility(.hidden, for: .tabBar) is how you ship a tab that's reachable from the sidebar but not the iPhone tab bar (useful for power-user destinations). Third, don't gate the customization behind a feature flag mid-release. The binary format is forward-compatible, but users with the older build will have their stored layout dropped on the floor.

Adding a search tab with TabRole.search

iOS 26 ships a dedicated TabRole.search that the system styles as a floating magnifying-glass affordance on iPhone and a pinned search row on iPad. You declare it the same way you declare any other tab; the role is the differentiator.

Tab(value: AppTab.search, role: .search) {
    NavigationStack { SearchView() }
}

You don't need to pass a title or system image for a search tab, because the role provides both. Inside the destination view, attach .searchable(text: $query) as you normally would, and the role wires the floating affordance to the same search field automatically. The accessory behaviour also means you shouldn't stack a custom search bar inside the destination, because users will have two ways to type and only one of them will be focused at a time. Honestly, I learned that the hard way during a beta cycle and ended up deleting half a screen of code.

Floating tab bar and tabBarMinimizeBehavior

The floating tab bar is the iOS 26 default look on iPad and the optional look on iPhone when you set .tabBarMinimizeBehavior. The behaviour modifier accepts .never, .onScrollDown, and .automatic, and the last one is sensitive to the embedded scroll view inside the destination.

TabView(selection: $selection) { ... }
    .tabViewStyle(.sidebarAdaptable)
    .tabBarMinimizeBehavior(.onScrollDown)

The bar shrinks to a pill when the user scrolls down and expands back when they scroll up, the same pattern Safari uses for its URL bar. For long-form reading screens it feels native; for transactional flows (checkout, settings) I set .never because users get anxious when controls disappear during a task. There's no way to animate the minimise yourself: the duration and easing are owned by the system, and overriding them would break muscle memory across apps. If you're layering custom animations elsewhere, my SwiftUI animations guide covers the spring and keyframe APIs you can compose around system-owned transitions safely.

How to hide the tab bar in SwiftUI

There are three legitimate ways to hide the tab bar in iOS 26 and one antipattern. The legitimate ones are: .toolbar(.hidden, for: .tabBar) applied inside a pushed destination, .tabBarMinimizeBehavior(.onScrollDown) for automatic context-aware hiding, and .defaultVisibility(.hidden, for: .tabBar) for a tab that should only appear in the sidebar. The antipattern is wrapping TabView in a custom ZStack and conditionally rendering a stand-in bar. Don't do that; it breaks every system gesture and the accessibility focus order.

struct DetailView: View {
    var body: some View {
        ScrollView { content }
            .toolbar(.hidden, for: .tabBar) // hides only while this view is on top
    }
}

The .toolbar(.hidden, for: .tabBar) modifier is scoped to the view it's attached to: push the destination and the bar disappears, pop back and it returns. This is the correct way to ship a full-screen reader or video player without losing the tab structure. If you need the bar to disappear globally for an authentication flow, present that flow as a .fullScreenCover instead. Covers sit above the tab bar by design.

Accessibility, VoiceOver, and keyboard navigation

Accessibility is where the new Tab API really pays off, but only if you set it up correctly. VoiceOver reads each tab as "Tab, [title], [position] of [count]", but only when the title is non-empty and the Tab has a real value. Empty strings break the count. Duplicate values collapse two tabs into one focus target. The system handles the swipe-to-next-tab gesture automatically.

For keyboard navigation on iPad and Mac, ⌘1 through ⌘9 automatically select tabs in declaration order. You don't need .keyboardShortcut on each Tab; adding it actually conflicts with the system mapping in iOS 26. Dynamic Type scales the tab labels through the .xxxLarge sizes; beyond that the bar switches to icons-only on iPhone with the label moving into VoiceOver speech. Test at Accessibility Inspector's "Larger Accessibility Sizes" before shipping. I've seen labels truncate mid-word on perfectly mainstream phones.

For the deeper accessibility patterns I apply to every shipping app (semantic traits, accessibility actions, custom rotors, reduce-motion handling), my SwiftUI accessibility guide goes through the full checklist. The two minimums for TabView specifically: every tab gets a real label, and the search tab gets a destination that actually focuses its search field on appear, not three taps later.

Migrating from the old selection-based TabView

Most production apps still have TabView code written against the iOS 16 API: child views with .tabItem modifiers and integer tags. Migration is mostly mechanical, but it has two real footguns.

  1. Replace integer tags with an enum. Define an enum conforming to Hashable and Codable; change your @AppStorage to use the new type with a default-value wrapper. The first launch after the upgrade should map old integers to enum cases. Write a migration in AppDelegate or your App's init that reads the legacy key, translates, then deletes it.
  2. Rewrite each .tabItem { Label(...) } into a Tab initializer. The argument order is title, system image, value, then content closure. Drop the surrounding NavigationView if it's still there and use NavigationStack inside the closure instead. The old NavigationView is deprecated and silently warns in iOS 26.
  3. Add customizationID to every tab before opting into .tabViewCustomization. Forgetting one tab means it can't be hidden, which users will report as a bug.
  4. Decide on a style. If your iPad layout has always been "tab bar at the bottom", switch to .sidebarAdaptable and audit each destination for the new wider canvas. If you previously hand-rolled a sidebar with NavigationSplitView, you can keep it; the styles are interoperable per-screen.

Concrete migration example. The before:

TabView(selection: $selection) {
    HomeView()
        .tabItem { Label("Home", systemImage: "house") }
        .tag(0)
    LibraryView()
        .tabItem { Label("Library", systemImage: "books.vertical") }
        .tag(1)
}

The after:

TabView(selection: $selection) {
    Tab("Home", systemImage: "house", value: AppTab.home) {
        NavigationStack { HomeView() }
    }
    Tab("Library", systemImage: "books.vertical", value: AppTab.library) {
        NavigationStack { LibraryView() }
    }
}
.tabViewStyle(.sidebarAdaptable)

For more on the broader iOS 26 framework changes that landed alongside the Tab API, the official TabView documentation and the Tab struct reference are the authoritative starting points. Apple's Human Interface Guidelines for tab bars covers the visual and behavioural rules the system now enforces.

Frequently Asked Questions

What replaces TabView in iOS 26?

Nothing replaces TabView; it's still the container. What changed is how you populate it: the Tab struct replaces the older child-view-with-.tabItem pattern, and TabSection groups tabs for sidebar adaptation. The old API still compiles but doesn't opt you into sidebar adaptation, customisation, the search role, or the minimise-on-scroll behaviour.

How do I add icons to TabView in SwiftUI?

Pass the SF Symbol name as the systemImage: argument when you construct each Tab. For example, Tab("Home", systemImage: "house", value: .home) { ... }. For a custom image asset, use the image: overload with an ImageResource. Pick symbols that have a .fill variant so the selected state animates smoothly.

How do you make a floating TabView in SwiftUI?

Apply .tabViewStyle(.sidebarAdaptable) to your TabView. On iPad the system renders the floating tab bar automatically; on iPhone, add .tabBarMinimizeBehavior(.onScrollDown) to opt into the pill-style minimised bar during scrolling. You don't draw the floating affordance yourself, and you shouldn't try.

Can I still use TabView with a Binding to an Int?

You can, but you forfeit the new features. The Tab initializer accepts any Hashable value, including Int, but typed enums prevent the "selection points at a tab that no longer exists" bug. Migrate to an enum on your next release and write a one-time defaults migration that maps old integers to cases.

Why is my TabView selection not persisting?

Either you passed selection: as a plain @State instead of @AppStorage, or your tab value type doesn't conform to RawRepresentable with a String or Int raw value. @AppStorage needs a raw representation to encode. Make your enum String-backed and the binding will survive process restarts.

Ava Thompson
About the Author Ava Thompson

SwiftUI engineer focused on declarative animations and accessibility. Will fight you about navigation stacks.