Scrolling Tab Strip
A horizontal row of tabs that scrolls the selected tab into the middle, with an underline that slides between tabs.

- Tabs
- iOS 18.0+
- beginner
- Origin
- Original work
- Found by
- tabs · tab strip · scrollable tabs · segmented · categories · underline · pestañas · tira de pestañas · pestañas desplazables · segmentos · categorías · subrayado
Description
Section titled “Description”A horizontal row of tabs that scrolls the selected tab into the middle.
With Reduce Motion on, the row and the underline jump to the new tab.
struct ScrollingTabStripDemo: View { private let tabs = ["For You", "Trending", "Music", "Sports", "Travel", "Food", "Science", "Design", "Movies", "Gaming"] private let symbols = ["sparkles", "flame.fill", "music.note", "figure.run", "airplane", "fork.knife", "atom", "paintbrush.fill", "film.fill", "gamecontroller.fill"]
@State private var selection = "For You"
private var index: Int { tabs.firstIndex(of: selection) ?? 0 }
var body: some View { VStack(spacing: 18) { ScrollingTabStrip(tabs: tabs, selection: $selection) .padding(.top, 12)
ZStack { RoundedRectangle(cornerRadius: 32) .fill(LinearGradient(colors: [.pink, .orange], startPoint: .topLeading, endPoint: .bottomTrailing)) .hueRotation(.degrees(Double(index) * 34))
VStack(spacing: 16) { Image(systemName: symbols[index]) .font(.largeTitle) .imageScale(.large) .contentTransition(.symbolEffect(.replace)) .accessibilityHidden(true)
Text("Stories about \(selection)") .font(.title.bold()) .multilineTextAlignment(.center) } .foregroundStyle(.white) .padding(24) } .padding(.horizontal, 20) .padding(.bottom, 20) } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
tabs | [String] | Required |
selection | Binding<String> | Required |
tint | Color | .pink |
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
/// A horizontal row of tabs that scrolls the selected tab into the middle.////// With Reduce Motion on, the row and the underline jump to the new tab.struct ScrollingTabStrip: View { let tabs: [String] @Binding var selection: String var tint: Color = .pink
@Namespace private var namespace @Environment(\.accessibilityReduceMotion) private var reduceMotion
private var animation: Animation? { reduceMotion ? nil : .snappy(duration: 0.35) }
var body: some View { ScrollViewReader { proxy in ScrollView(.horizontal) { HStack(spacing: 22) { ForEach(tabs, id: \.self) { tab in tabButton(tab) .id(tab) } } .padding(.horizontal, 20) } .scrollIndicators(.hidden) .onChange(of: selection, initial: true) { _, newValue in withAnimation(animation) { proxy.scrollTo(newValue, anchor: .center) } } } }
private func tabButton(_ tab: String) -> some View { let isSelected = tab == selection
return Button { withAnimation(animation) { selection = tab } } label: { VStack(spacing: 8) { Text(tab) .font(.headline) .foregroundStyle(isSelected ? .primary : .secondary)
ZStack { Capsule().fill(.clear)
if isSelected { Capsule() .fill(tint) .matchedGeometryEffect(id: "underline", in: namespace) } } .frame(height: 3) } .fixedSize() // A tab is shorter than a touch target. The padding widens what answers to a // touch, and the same padding taken back keeps the strip as it was. .padding(.horizontal, 2) .padding(.vertical, 7) .contentShape(.rect) .padding(.horizontal, -2) .padding(.vertical, -7) } .buttonStyle(.plain) .accessibilityLabel(tab) .accessibilityAddTraits(isSelected ? .isSelected : []) }}
// MARK: - Demo
struct ScrollingTabStripDemo: View { private let tabs = ["For You", "Trending", "Music", "Sports", "Travel", "Food", "Science", "Design", "Movies", "Gaming"] private let symbols = ["sparkles", "flame.fill", "music.note", "figure.run", "airplane", "fork.knife", "atom", "paintbrush.fill", "film.fill", "gamecontroller.fill"]
@State private var selection = "For You"
private var index: Int { tabs.firstIndex(of: selection) ?? 0 }
var body: some View { VStack(spacing: 18) { ScrollingTabStrip(tabs: tabs, selection: $selection) .padding(.top, 12)
ZStack { RoundedRectangle(cornerRadius: 32) .fill(LinearGradient(colors: [.pink, .orange], startPoint: .topLeading, endPoint: .bottomTrailing)) .hueRotation(.degrees(Double(index) * 34))
VStack(spacing: 16) { Image(systemName: symbols[index]) .font(.largeTitle) .imageScale(.large) .contentTransition(.symbolEffect(.replace)) .accessibilityHidden(true)
Text("Stories about \(selection)") .font(.title.bold()) .multilineTextAlignment(.center) } .foregroundStyle(.white) .padding(24) } .padding(.horizontal, 20) .padding(.bottom, 20) } }}
#Preview { ScrollingTabStripDemo()}