Tabs Picker Pages
A row of tabs over a page that slides in with them, on the tabs picker style of iOS 27.

- Tabs
- iOS 27.0+
- beginner
- Origin
- Original work
- Found by
- tabs · picker style · segmented · pages · sections · top tabs · ios 27 · pestañas · estilo de picker · segmentado · páginas · secciones · pestañas superiores · ios 27
Description
Section titled “Description”A row of tabs over a page that changes with them, using the tabs picker style that iOS 27 added to Picker. On an iPhone the system draws it much like a segmented control, with the text of each label.
The new page slides in from the side its tab is on. It is a system Picker, so VoiceOver and the keyboard are the system's. With Reduce Motion on, the pages cross-fade.
@available(iOS 27, *)struct TabsPickerPagesDemo: View { private enum Section: String, CaseIterable { case overview = "Overview" case activity = "Activity" case files = "Files"
var symbol: String { switch self { case .overview: "square.grid.2x2" case .activity: "chart.line.uptrend.xyaxis" case .files: "folder" } }
var rows: [String] { switch self { case .overview: ["Launch plan", "Budget", "Team"] case .activity: ["Ana updated the budget", "Leo added two files", "Mia closed a task"] case .files: ["Brief.pdf", "Timeline.numbers", "Logo.svg", "Notes.txt"] } } }
@State private var section = Section.overview
var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Spring Launch") .font(.largeTitle.bold())
TabsPickerPages(options: Section.allCases, selection: $section) { section in SwiftUI.Label(section.rawValue, systemImage: section.symbol) } page: { section in VStack(spacing: 10) { ForEach(section.rows, id: \.self) { row in Text(row) .frame(maxWidth: .infinity, alignment: .leading) .padding(16) .background(.background, in: .rect(cornerRadius: 14)) } } } } .padding(20) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(.systemGroupedBackground)) }}
@available(iOS 27, *)API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
options | [Option] | Required |
selection | Binding<Option> | Required |
title | String | "Section" |
label | (Option) -> Label | Required |
page | (Option) -> Page | Required |
Copy this code into your project, or download the file.
// SPDX-License-Identifier: MIT// Copyright (c) 2026 Oscar Canton and SwiftUI Blocks contributors
import SwiftUI
nonisolated enum TabsPickerPagesLogic { /// Whether going from one option to another moves forward through the options, which decides /// the side the new page comes in from. An option that is not listed counts as the first. static func movesForward<Option: Equatable>(from old: Option, to new: Option, in options: [Option]) -> Bool { (options.firstIndex(of: new) ?? 0) >= (options.firstIndex(of: old) ?? 0) }}
/// A row of tabs over a page that changes with them, using the tabs picker style that iOS 27/// added to `Picker`. On an iPhone the system draws it much like a segmented control, with/// the text of each label.////// The new page slides in from the side its tab is on. It is a system `Picker`, so VoiceOver/// and the keyboard are the system's. With Reduce Motion on, the pages cross-fade.@available(iOS 27, *)struct TabsPickerPages<Option: Hashable, Label: View, Page: View>: View { let options: [Option] @Binding var selection: Option var title = "Section" @ViewBuilder var label: (Option) -> Label @ViewBuilder var page: (Option) -> Page
@State private var movesForward = true @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { VStack(spacing: 16) { Picker(title, selection: $selection) { ForEach(options, id: \.self) { option in label(option) .tag(option) } } .pickerStyle(.tabs)
page(selection) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .id(selection) .transition(transition) } .onChange(of: selection) { old, new in movesForward = TabsPickerPagesLogic.movesForward(from: old, to: new, in: options) } .animation(reduceMotion ? .easeInOut(duration: 0.2) : .snappy(duration: 0.35), value: selection) }
private var transition: AnyTransition { guard !reduceMotion else { return .opacity } return .asymmetric( insertion: .move(edge: movesForward ? .trailing : .leading).combined(with: .opacity), removal: .move(edge: movesForward ? .leading : .trailing).combined(with: .opacity) ) }}
// MARK: - Demo
@available(iOS 27, *)struct TabsPickerPagesDemo: View { private enum Section: String, CaseIterable { case overview = "Overview" case activity = "Activity" case files = "Files"
var symbol: String { switch self { case .overview: "square.grid.2x2" case .activity: "chart.line.uptrend.xyaxis" case .files: "folder" } }
var rows: [String] { switch self { case .overview: ["Launch plan", "Budget", "Team"] case .activity: ["Ana updated the budget", "Leo added two files", "Mia closed a task"] case .files: ["Brief.pdf", "Timeline.numbers", "Logo.svg", "Notes.txt"] } } }
@State private var section = Section.overview
var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Spring Launch") .font(.largeTitle.bold())
TabsPickerPages(options: Section.allCases, selection: $section) { section in SwiftUI.Label(section.rawValue, systemImage: section.symbol) } page: { section in VStack(spacing: 10) { ForEach(section.rows, id: \.self) { row in Text(row) .frame(maxWidth: .infinity, alignment: .leading) .padding(16) .background(.background, in: .rect(cornerRadius: 14)) } } } } .padding(20) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(.systemGroupedBackground)) }}
@available(iOS 27, *)#Preview { TabsPickerPagesDemo()}