Page Dots Indicator
Page dots where the current one stretches into a capsule, following a paged TabView or scroll.

- Navigation
- iOS 18.0+
- beginner
- Origin
- Original work
- Found by
- page control · dots · pagination · carousel · indicator · onboarding · capsule · puntos de página · puntos · paginación · carrusel · indicador · bienvenida · cápsula
Description
Section titled “Description”Page dots where the current one stretches into a capsule. Bind current to a paged TabView or a scroll position.
With Reduce Motion on, the capsule changes without animating.
struct PageDotsIndicatorDemo: View { private let pages: [(title: String, symbol: String, colors: [Color])] = [ ("Plan your trip", "map.fill", [.blue, .indigo]), ("Book in seconds", "ticket.fill", [.pink, .orange]), ("Pack with a list", "suitcase.fill", [.teal, .green]), ("Share the memories", "camera.fill", [.purple, .pink]), ]
@State private var page = 0
var body: some View { VStack(spacing: 24) { TabView(selection: $page) { ForEach(pages.indices, id: \.self) { index in VStack(spacing: 24) { Image(systemName: pages[index].symbol) .font(.largeTitle) .imageScale(.large) .accessibilityHidden(true)
Text(pages[index].title) .font(.title.bold()) } .foregroundStyle(.white) .frame(maxWidth: .infinity, maxHeight: .infinity) .background( LinearGradient(colors: pages[index].colors, startPoint: .topLeading, endPoint: .bottomTrailing), in: .rect(cornerRadius: 32) ) .padding(.horizontal, 20) .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never))
PageDotsIndicator(count: pages.count, current: page) .padding(.bottom, 16) } }}API reference
Section titled “API reference”| Parameter | Type | Default |
|---|---|---|
count | Int | Required |
current | Int | Required |
tint | Color | .primary |
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
/// Page dots where the current one stretches into a capsule. Bind `current` to a paged `TabView` or a scroll position.////// With Reduce Motion on, the capsule changes without animating.struct PageDotsIndicator: View { let count: Int let current: Int var tint: Color = .primary
@ScaledMetric(relativeTo: .caption) private var dotSize = 8.0 @Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View { HStack(spacing: dotSize) { ForEach(0..<max(count, 0), id: \.self) { index in Capsule() .fill(tint.opacity(index == current ? 1 : 0.25)) .frame(width: index == current ? dotSize * 3 : dotSize, height: dotSize) } } .animation(reduceMotion ? nil : .spring(duration: 0.35, bounce: 0.3), value: current) .accessibilityElement(children: .ignore) .accessibilityLabel("Page") .accessibilityValue("\(current + 1) of \(count)") }}
// MARK: - Demo
struct PageDotsIndicatorDemo: View { private let pages: [(title: String, symbol: String, colors: [Color])] = [ ("Plan your trip", "map.fill", [.blue, .indigo]), ("Book in seconds", "ticket.fill", [.pink, .orange]), ("Pack with a list", "suitcase.fill", [.teal, .green]), ("Share the memories", "camera.fill", [.purple, .pink]), ]
@State private var page = 0
var body: some View { VStack(spacing: 24) { TabView(selection: $page) { ForEach(pages.indices, id: \.self) { index in VStack(spacing: 24) { Image(systemName: pages[index].symbol) .font(.largeTitle) .imageScale(.large) .accessibilityHidden(true)
Text(pages[index].title) .font(.title.bold()) } .foregroundStyle(.white) .frame(maxWidth: .infinity, maxHeight: .infinity) .background( LinearGradient(colors: pages[index].colors, startPoint: .topLeading, endPoint: .bottomTrailing), in: .rect(cornerRadius: 32) ) .padding(.horizontal, 20) .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never))
PageDotsIndicator(count: pages.count, current: page) .padding(.bottom, 16) } }}
#Preview { PageDotsIndicatorDemo()}