# Page Indicator

A row of dots that shows the current page in a pager or carousel, with an elongated dot for the active page.

Page Indicator is an open-source component from suiss/ui for React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/page-indicator

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-page-indicator--default.light.webp)

```tsx
import { PageIndicator } from "@/components/ui/page-indicator"

export function PageIndicatorExample() {
  return (
    <PageIndicator count={5} index={1} />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct PageIndicatorExample: View {
  var body: some View {
    PageIndicator(count: 5, index: 1)
  }
}
```

```kotlin
import androidx.compose.runtime.Composable
import com.esuiss.ui.components.PageIndicator

@Composable
fun PageIndicatorExample() {
    PageIndicator(count = 5, index = 1)
}
```

`PageIndicator` draws one dot per page and widens the active one into a pill. Width and position changes animate with Reanimated. When `count` exceeds `maxVisible`, only a window of dots is shown; the window slides to keep the active dot near the center and the dots at its edges shrink to hint that more pages exist.

It comes in two sizes, `default` and `sm`, and two variants: `default` uses the foreground color for the active dot and `accent` uses the primary color.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/page-indicator
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add page-indicator
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add page-indicator --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add page-indicator --platform kotlin
```

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: the primitives `components/primitives/haptics.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/page-indicator.tsx`:

```tsx title="uniwind/components/ui/page-indicator.tsx"
import { cn } from "cn"
import * as React from "react"
import {
  type AccessibilityActionEvent,
  Pressable,
  View,
  type ViewProps,
} from "react-native"
import {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { AnimatedView } from "@/components/ui/animated-view"

type PageIndicatorSize = "default" | "sm"

type PageIndicatorVariant = "default" | "accent"

const SIZES: Record<
  PageIndicatorSize,
  { dot: number; active: number; gap: number }
> = {
  default: { dot: 8, active: 20, gap: 8 },
  sm: { dot: 6, active: 14, gap: 6 },
}

const TIMING = {
  duration: 300,
  easing: Easing.bezier(0.32, 0.72, 0, 1),
  reduceMotion: ReduceMotion.System,
}

const ACTIONS = [{ name: "increment" }, { name: "decrement" }]

function actionStep(actionName: string) {
  if (actionName === "increment") return 1
  if (actionName === "decrement") return -1
  return 0
}

function windowStart(count: number, index: number, maxVisible: number) {
  if (count <= maxVisible) return 0
  return Math.min(
    Math.max(index - Math.floor(maxVisible / 2), 0),
    count - maxVisible
  )
}

function dotScale(
  position: number,
  start: number,
  count: number,
  maxVisible: number
) {
  if (count <= maxVisible) return 1
  const offset = position - start
  if (offset < 0 || offset >= maxVisible) return 0
  const end = start + maxVisible < count
  if (start > 0 && offset === 0) return 0.5
  if (start > 0 && offset === 1) return 0.75
  if (end && offset === maxVisible - 1) return 0.5
  if (end && offset === maxVisible - 2) return 0.75
  return 1
}

function PageIndicatorDot({
  active,
  scale,
  size,
  variant,
  onPress,
}: {
  active: boolean
  scale: number
  size: PageIndicatorSize
  variant: PageIndicatorVariant
  onPress?: () => void
}) {
  const metrics = SIZES[size]
  const width = useSharedValue(active ? metrics.active : metrics.dot)
  const dotScaleValue = useSharedValue(scale)

  React.useEffect(() => {
    width.value = withTiming(active ? metrics.active : metrics.dot, TIMING)
  }, [active, metrics, width])

  React.useEffect(() => {
    dotScaleValue.value = withTiming(scale, TIMING)
  }, [scale, dotScaleValue])

  const animatedStyle = useAnimatedStyle(() => ({
    width: width.value,
    transform: [{ scale: dotScaleValue.value }],
  }))

  return (
    <Pressable
      accessible={false}
      disabled={!onPress}
      onPress={onPress}
      hitSlop={{
        top: 8,
        bottom: 8,
        left: metrics.gap / 2,
        right: metrics.gap / 2,
      }}
    >
      <AnimatedView
        className={cn(
          "rounded-full",
          active && variant === "accent" && "bg-primary",
          active && variant !== "accent" && "bg-foreground",
          !active && "bg-border"
        )}
        style={[{ height: metrics.dot }, animatedStyle]}
      />
    </Pressable>
  )
}

function PageIndicator({
  count,
  index,
  onIndexChange,
  size = "default",
  variant = "default",
  maxVisible = 7,
  accessibilityLabel = "Page",
  className,
  ...props
}: Omit<ViewProps, "children"> & {
  count: number
  index: number
  onIndexChange?: (index: number) => void
  size?: PageIndicatorSize
  variant?: PageIndicatorVariant
  maxVisible?: number
}) {
  const haptics = useHaptics()
  const metrics = SIZES[size]
  const visible = Math.max(3, maxVisible)
  const current = Math.min(Math.max(index, 0), Math.max(0, count - 1))
  const start = windowStart(count, current, visible)
  const shown = Math.min(count, visible)
  const frameWidth =
    shown * metrics.dot +
    (shown - 1) * metrics.gap +
    metrics.active -
    metrics.dot
  const offset = useSharedValue(-start * (metrics.dot + metrics.gap))

  React.useEffect(() => {
    offset.value = withTiming(-start * (metrics.dot + metrics.gap), TIMING)
  }, [start, metrics, offset])

  const rowStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: offset.value }],
  }))

  if (count <= 0) return null

  const select = (next: number) => {
    if (!onIndexChange || next === current) return
    haptics.selection()
    onIndexChange(next)
  }

  const onAccessibilityAction = (event: AccessibilityActionEvent) => {
    if (!onIndexChange) return
    const step = actionStep(event.nativeEvent.actionName)
    const next = Math.min(Math.max(current + step, 0), count - 1)
    if (next !== current) onIndexChange(next)
  }

  return (
    <View
      {...props}
      accessible
      accessibilityRole="adjustable"
      accessibilityLabel={accessibilityLabel}
      accessibilityValue={{ text: `${current + 1} of ${count}` }}
      accessibilityActions={onIndexChange ? ACTIONS : undefined}
      onAccessibilityAction={onAccessibilityAction}
      className={cn("items-center justify-center self-center", className)}
    >
      <View
        className="justify-center overflow-hidden"
        style={{ width: frameWidth, height: metrics.dot + 16 }}
      >
        <AnimatedView
          className="flex-row items-center"
          style={[{ gap: metrics.gap }, rowStyle]}
        >
          {Array.from({ length: count }, (_, position) => position).map(
            (position) => (
              <PageIndicatorDot
                key={position}
                active={position === current}
                scale={dotScale(position, start, count, visible)}
                size={size}
                variant={variant}
                onPress={onIndexChange ? () => select(position) : undefined}
              />
            )
          )}
        </AnimatedView>
      </View>
    </View>
  )
}

export type { PageIndicatorSize, PageIndicatorVariant }
export { PageIndicator }
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: the primitives `components/primitives/haptics.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/page-indicator.tsx`:

```tsx title="nativewind/components/ui/page-indicator.tsx"
import { cn } from "cn"
import * as React from "react"
import {
  type AccessibilityActionEvent,
  Pressable,
  View,
  type ViewProps,
} from "react-native"
import {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { AnimatedView } from "@/components/ui/animated-view"

type PageIndicatorSize = "default" | "sm"

type PageIndicatorVariant = "default" | "accent"

const SIZES: Record<
  PageIndicatorSize,
  { dot: number; active: number; gap: number }
> = {
  default: { dot: 8, active: 20, gap: 8 },
  sm: { dot: 6, active: 14, gap: 6 },
}

const TIMING = {
  duration: 300,
  easing: Easing.bezier(0.32, 0.72, 0, 1),
  reduceMotion: ReduceMotion.System,
}

const ACTIONS = [{ name: "increment" }, { name: "decrement" }]

function actionStep(actionName: string) {
  if (actionName === "increment") return 1
  if (actionName === "decrement") return -1
  return 0
}

function windowStart(count: number, index: number, maxVisible: number) {
  if (count <= maxVisible) return 0
  return Math.min(
    Math.max(index - Math.floor(maxVisible / 2), 0),
    count - maxVisible
  )
}

function dotScale(
  position: number,
  start: number,
  count: number,
  maxVisible: number
) {
  if (count <= maxVisible) return 1
  const offset = position - start
  if (offset < 0 || offset >= maxVisible) return 0
  const end = start + maxVisible < count
  if (start > 0 && offset === 0) return 0.5
  if (start > 0 && offset === 1) return 0.75
  if (end && offset === maxVisible - 1) return 0.5
  if (end && offset === maxVisible - 2) return 0.75
  return 1
}

function PageIndicatorDot({
  active,
  scale,
  size,
  variant,
  onPress,
}: {
  active: boolean
  scale: number
  size: PageIndicatorSize
  variant: PageIndicatorVariant
  onPress?: () => void
}) {
  const metrics = SIZES[size]
  const width = useSharedValue(active ? metrics.active : metrics.dot)
  const dotScaleValue = useSharedValue(scale)

  React.useEffect(() => {
    width.value = withTiming(active ? metrics.active : metrics.dot, TIMING)
  }, [active, metrics, width])

  React.useEffect(() => {
    dotScaleValue.value = withTiming(scale, TIMING)
  }, [scale, dotScaleValue])

  const animatedStyle = useAnimatedStyle(() => ({
    width: width.value,
    transform: [{ scale: dotScaleValue.value }],
  }))

  return (
    <Pressable
      accessible={false}
      disabled={!onPress}
      onPress={onPress}
      hitSlop={{
        top: 8,
        bottom: 8,
        left: metrics.gap / 2,
        right: metrics.gap / 2,
      }}
    >
      <AnimatedView
        className={cn(
          "rounded-full",
          active && variant === "accent" && "bg-primary",
          active && variant !== "accent" && "bg-foreground",
          !active && "bg-border"
        )}
        style={[{ height: metrics.dot }, animatedStyle]}
      />
    </Pressable>
  )
}

function PageIndicator({
  count,
  index,
  onIndexChange,
  size = "default",
  variant = "default",
  maxVisible = 7,
  accessibilityLabel = "Page",
  className,
  ...props
}: Omit<ViewProps, "children"> & {
  count: number
  index: number
  onIndexChange?: (index: number) => void
  size?: PageIndicatorSize
  variant?: PageIndicatorVariant
  maxVisible?: number
}) {
  const haptics = useHaptics()
  const metrics = SIZES[size]
  const visible = Math.max(3, maxVisible)
  const current = Math.min(Math.max(index, 0), Math.max(0, count - 1))
  const start = windowStart(count, current, visible)
  const shown = Math.min(count, visible)
  const frameWidth =
    shown * metrics.dot +
    (shown - 1) * metrics.gap +
    metrics.active -
    metrics.dot
  const offset = useSharedValue(-start * (metrics.dot + metrics.gap))

  React.useEffect(() => {
    offset.value = withTiming(-start * (metrics.dot + metrics.gap), TIMING)
  }, [start, metrics, offset])

  const rowStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: offset.value }],
  }))

  if (count <= 0) return null

  const select = (next: number) => {
    if (!onIndexChange || next === current) return
    haptics.selection()
    onIndexChange(next)
  }

  const onAccessibilityAction = (event: AccessibilityActionEvent) => {
    if (!onIndexChange) return
    const step = actionStep(event.nativeEvent.actionName)
    const next = Math.min(Math.max(current + step, 0), count - 1)
    if (next !== current) onIndexChange(next)
  }

  return (
    <View
      {...props}
      accessible
      accessibilityRole="adjustable"
      accessibilityLabel={accessibilityLabel}
      accessibilityValue={{ text: `${current + 1} of ${count}` }}
      accessibilityActions={onIndexChange ? ACTIONS : undefined}
      onAccessibilityAction={onAccessibilityAction}
      className={cn("items-center justify-center self-center", className)}
    >
      <View
        className="justify-center overflow-hidden"
        style={{ width: frameWidth, height: metrics.dot + 16 }}
      >
        <AnimatedView
          className="flex-row items-center"
          style={[{ gap: metrics.gap }, rowStyle]}
        >
          {Array.from({ length: count }, (_, position) => position).map(
            (position) => (
              <PageIndicatorDot
                key={position}
                active={position === current}
                scale={dotScale(position, start, count, visible)}
                size={size}
                variant={variant}
                onPress={onIndexChange ? () => select(position) : undefined}
              />
            )
          )}
        </AnimatedView>
      </View>
    </View>
  )
}

export type { PageIndicatorSize, PageIndicatorVariant }
export { PageIndicator }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: the primitives `components/primitives/haptics.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/ui/page-indicator.tsx`:

```tsx title="stylesheet/components/ui/page-indicator.tsx"
import * as React from "react"
import {
  type AccessibilityActionEvent,
  Pressable,
  StyleSheet,
  View,
  type ViewProps,
} from "react-native"
import {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"
import { useHaptics } from "@/components/primitives/haptics"
import { AnimatedView } from "@/components/ui/animated-view"
import { radius, useTheme } from "@/components/ui/theme"

type PageIndicatorSize = "default" | "sm"

type PageIndicatorVariant = "default" | "accent"

const SIZES: Record<
  PageIndicatorSize,
  { dot: number; active: number; gap: number }
> = {
  default: { dot: 8, active: 20, gap: 8 },
  sm: { dot: 6, active: 14, gap: 6 },
}

const TIMING = {
  duration: 300,
  easing: Easing.bezier(0.32, 0.72, 0, 1),
  reduceMotion: ReduceMotion.System,
}

function actionStep(actionName: string) {
  if (actionName === "increment") return 1
  if (actionName === "decrement") return -1
  return 0
}

const styles = StyleSheet.create({
  root: { alignItems: "center", justifyContent: "center", alignSelf: "center" },
  frame: { justifyContent: "center", overflow: "hidden" },
  row: { flexDirection: "row", alignItems: "center" },
  dot: { borderRadius: radius.full },
})

const ACTIONS = [{ name: "increment" }, { name: "decrement" }]

function windowStart(count: number, index: number, maxVisible: number) {
  if (count <= maxVisible) return 0
  return Math.min(
    Math.max(index - Math.floor(maxVisible / 2), 0),
    count - maxVisible
  )
}

function dotScale(
  position: number,
  start: number,
  count: number,
  maxVisible: number
) {
  if (count <= maxVisible) return 1
  const offset = position - start
  if (offset < 0 || offset >= maxVisible) return 0
  const end = start + maxVisible < count
  if (start > 0 && offset === 0) return 0.5
  if (start > 0 && offset === 1) return 0.75
  if (end && offset === maxVisible - 1) return 0.5
  if (end && offset === maxVisible - 2) return 0.75
  return 1
}

function PageIndicatorDot({
  active,
  scale,
  size,
  variant,
  onPress,
}: {
  active: boolean
  scale: number
  size: PageIndicatorSize
  variant: PageIndicatorVariant
  onPress?: () => void
}) {
  const { colors } = useTheme()
  const metrics = SIZES[size]
  const width = useSharedValue(active ? metrics.active : metrics.dot)
  const dotScaleValue = useSharedValue(scale)

  React.useEffect(() => {
    width.value = withTiming(active ? metrics.active : metrics.dot, TIMING)
  }, [active, metrics, width])

  React.useEffect(() => {
    dotScaleValue.value = withTiming(scale, TIMING)
  }, [scale, dotScaleValue])

  const animatedStyle = useAnimatedStyle(() => ({
    width: width.value,
    transform: [{ scale: dotScaleValue.value }],
  }))

  const activeColor = variant === "accent" ? colors.primary : colors.foreground

  return (
    <Pressable
      accessible={false}
      disabled={!onPress}
      onPress={onPress}
      hitSlop={{
        top: 8,
        bottom: 8,
        left: metrics.gap / 2,
        right: metrics.gap / 2,
      }}
    >
      <AnimatedView
        style={[
          styles.dot,
          {
            height: metrics.dot,
            backgroundColor: active ? activeColor : colors.border,
          },
          animatedStyle,
        ]}
      />
    </Pressable>
  )
}

function PageIndicator({
  count,
  index,
  onIndexChange,
  size = "default",
  variant = "default",
  maxVisible = 7,
  accessibilityLabel = "Page",
  style,
  ...props
}: Omit<ViewProps, "children"> & {
  count: number
  index: number
  onIndexChange?: (index: number) => void
  size?: PageIndicatorSize
  variant?: PageIndicatorVariant
  maxVisible?: number
}) {
  const haptics = useHaptics()
  const metrics = SIZES[size]
  const visible = Math.max(3, maxVisible)
  const current = Math.min(Math.max(index, 0), Math.max(0, count - 1))
  const start = windowStart(count, current, visible)
  const shown = Math.min(count, visible)
  const frameWidth =
    shown * metrics.dot +
    (shown - 1) * metrics.gap +
    metrics.active -
    metrics.dot
  const offset = useSharedValue(-start * (metrics.dot + metrics.gap))

  React.useEffect(() => {
    offset.value = withTiming(-start * (metrics.dot + metrics.gap), TIMING)
  }, [start, metrics, offset])

  const rowStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: offset.value }],
  }))

  if (count <= 0) return null

  const select = (next: number) => {
    if (!onIndexChange || next === current) return
    haptics.selection()
    onIndexChange(next)
  }

  const onAccessibilityAction = (event: AccessibilityActionEvent) => {
    if (!onIndexChange) return
    const step = actionStep(event.nativeEvent.actionName)
    const next = Math.min(Math.max(current + step, 0), count - 1)
    if (next !== current) onIndexChange(next)
  }

  return (
    <View
      {...props}
      accessible
      accessibilityRole="adjustable"
      accessibilityLabel={accessibilityLabel}
      accessibilityValue={{ text: `${current + 1} of ${count}` }}
      accessibilityActions={onIndexChange ? ACTIONS : undefined}
      onAccessibilityAction={onAccessibilityAction}
      style={[styles.root, style]}
    >
      <View
        style={[styles.frame, { width: frameWidth, height: metrics.dot + 16 }]}
      >
        <AnimatedView style={[styles.row, { gap: metrics.gap }, rowStyle]}>
          {Array.from({ length: count }, (_, position) => position).map(
            (position) => (
              <PageIndicatorDot
                key={position}
                active={position === current}
                scale={dotScale(position, start, count, visible)}
                size={size}
                variant={variant}
                onPress={onIndexChange ? () => select(position) : undefined}
              />
            )
          )}
        </AnimatedView>
      </View>
    </View>
  )
}

export type { PageIndicatorSize, PageIndicatorVariant }
export { PageIndicator }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

```swift title="swift/Components/PageIndicator.swift"
import SwiftUI

public enum PageIndicatorSize: CaseIterable, Sendable {
  case sm, `default`

  var dot: CGFloat {
    switch self {
    case .sm: 6
    case .default: 8
    }
  }

  var activeWidth: CGFloat {
    switch self {
    case .sm: 16
    case .default: 24
    }
  }

  var spacing: CGFloat {
    switch self {
    case .sm: 6
    case .default: 8
    }
  }
}

public enum PageIndicatorVariant: CaseIterable, Sendable {
  case `default`, accent
}

struct PageIndicatorDot: Equatable, Sendable {
  let page: Int
  let scale: CGFloat
}

enum PageIndicatorMath {
  static let minimumVisible = 5

  static func window(count: Int, index: Int, maxVisible: Int) -> Range<Int> {
    let visible = max(maxVisible, minimumVisible)
    guard count > visible else { return 0..<max(count, 0) }
    let current = CarouselMath.clamp(index, count: count)
    let start = min(max(current - visible / 2, 0), count - visible)
    return start..<(start + visible)
  }

  static func dots(count: Int, index: Int, maxVisible: Int) -> [PageIndicatorDot] {
    let range = window(count: count, index: index, maxVisible: maxVisible)
    let hiddenBefore = range.lowerBound > 0
    let hiddenAfter = range.upperBound < count
    return range.map { page in
      let fromStart = page - range.lowerBound
      let fromEnd = range.upperBound - 1 - page
      var scale: CGFloat = 1
      if hiddenBefore && fromStart < 2 { scale = min(scale, fromStart == 0 ? 0.5 : 0.75) }
      if hiddenAfter && fromEnd < 2 { scale = min(scale, fromEnd == 0 ? 0.5 : 0.75) }
      return PageIndicatorDot(page: page, scale: scale)
    }
  }
}

public struct PageIndicator: View {
  public typealias Size = PageIndicatorSize
  public typealias Variant = PageIndicatorVariant

  let count: Int
  let index: Int
  let size: Size
  let variant: Variant
  let maxVisible: Int
  let onIndexChange: ((Int) -> Void)?

  @State private var taps = 0
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    count: Int,
    index: Int,
    size: Size = .default,
    variant: Variant = .default,
    maxVisible: Int = 7,
    onIndexChange: ((Int) -> Void)? = nil
  ) {
    self.count = count
    self.index = index
    self.size = size
    self.variant = variant
    self.maxVisible = maxVisible
    self.onIndexChange = onIndexChange
  }

  public var body: some View {
    let current = CarouselMath.clamp(index, count: count)
    let dots = PageIndicatorMath.dots(count: count, index: current, maxVisible: maxVisible)
    HStack(spacing: size.spacing) {
      ForEach(dots, id: \.page) { dot in
        let active = dot.page == current
        Capsule()
          .fill(active ? activeColor : EsuissColor.border)
          .frame(width: active ? size.activeWidth : size.dot, height: size.dot)
          .scaleEffect(dot.scale)
          .padding(.vertical, size.dot)
          .contentShape(Rectangle())
          .onTapGesture {
            if let onIndexChange, dot.page != current {
              taps += 1
              onIndexChange(dot.page)
            }
          }
          .transition(.scale.combined(with: .opacity))
      }
    }
    .animation(reduceMotion ? nil : carouselAnimation, value: current)
    .hapticFeedback(.selection, trigger: taps)
    .accessibilityElement(children: .ignore)
    .accessibilityLabel(Text("Page"))
    .accessibilityValue(Text("\(current + 1) of \(count)"))
    .accessibilityAdjustableAction { direction in
      guard let onIndexChange else { return }
      switch direction {
      case .increment: if current < count - 1 { onIndexChange(current + 1) }
      case .decrement: if current > 0 { onIndexChange(current - 1) }
      @unknown default: break
      }
    }
  }

  private var activeColor: Color {
    switch variant {
    case .default: EsuissColor.foreground
    case .accent: EsuissColor.primary
    }
  }
}
```

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Carousel.swift`: see [Carousel](https://ui.suiss.app/docs/components/carousel.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

```kt title="kotlin/components/PageIndicator.kt"
package com.esuiss.ui.components

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme

enum class PageIndicatorSize(val dot: Dp, val active: Dp, val gap: Dp) {
    Sm(6.dp, 14.dp, 6.dp),
    Default(8.dp, 20.dp, 8.dp),
}

enum class PageIndicatorVariant { Default, Accent }

@Composable
fun PageIndicator(
    count: Int,
    index: Int,
    modifier: Modifier = Modifier,
    onIndexChange: ((Int) -> Unit)? = null,
    size: PageIndicatorSize = PageIndicatorSize.Default,
    variant: PageIndicatorVariant = PageIndicatorVariant.Default,
    maxVisible: Int = 7,
    accessibilityLabel: String? = null,
) {
    if (count <= 0) return
    val colors = EsuissTheme.colors
    val haptics = rememberHaptics()
    val current = index.coerceIn(0, count - 1)
    val visible = maxVisible.coerceAtLeast(5)
    val start = if (count <= visible) 0 else (current - visible / 2).coerceIn(0, count - visible)
    val end = if (count <= visible) count else start + visible
    val activeColor = if (variant == PageIndicatorVariant.Accent) colors.primary else colors.foreground
    val summary = "Page ${current + 1} of $count"
    Row(
        modifier = modifier
            .height(24.dp)
            .then(
                if (onIndexChange == null) {
                    Modifier.clearAndSetSemantics {
                        contentDescription = accessibilityLabel ?: summary
                        if (accessibilityLabel != null) stateDescription = summary
                    }
                } else {
                    Modifier.semantics {
                        if (accessibilityLabel != null) contentDescription = accessibilityLabel
                        stateDescription = summary
                    }
                },
            ),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        for (page in start until end) {
            key(page) {
                val position = page - start
                val edge = when {
                    start > 0 && position == 0 -> 0.5f
                    start > 0 && position == 1 -> 0.75f
                    end < count && position == visible - 1 -> 0.5f
                    end < count && position == visible - 2 -> 0.75f
                    else -> 1f
                }
                val active = page == current
                val scale by animateFloatAsState(edge, tween(250), label = "scale")
                val width by animateDpAsState(if (active) size.active else size.dot, tween(300, easing = CarouselEasing), label = "width")
                val color by animateColorAsState(if (active) activeColor else colors.border, tween(300), label = "color")
                Box(
                    modifier = Modifier
                        .height(24.dp)
                        .then(
                            if (onIndexChange != null) {
                                Modifier
                                    .clickable(
                                        interactionSource = remember { MutableInteractionSource() },
                                        indication = null,
                                        role = Role.Button,
                                    ) {
                                        if (page != current) haptics.selection()
                                        onIndexChange(page)
                                    }
                                    .semantics {
                                        contentDescription = "Page ${page + 1}"
                                        selected = active
                                    }
                            } else {
                                Modifier
                            },
                        )
                        .padding(horizontal = size.gap / 2),
                    contentAlignment = Alignment.Center,
                ) {
                    Box(
                        modifier = Modifier
                            .graphicsLayer {
                                scaleX = scale
                                scaleY = scale
                            }
                            .width(width)
                            .height(size.dot)
                            .clip(CircleShape)
                            .background(color, CircleShape),
                    )
                }
            }
        }
    }
}
```

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Carousel.kt`: see [Carousel](https://ui.suiss.app/docs/components/carousel.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { PageIndicator } from "@/components/ui/page-indicator"
```

```tsx
<PageIndicator count={5} index={1} />
```

The component is controlled: pass `count` and the current `index`. Pass `onIndexChange` to make the dots pressable; without it the indicator is display-only. Pressing a dot plays a selection haptic.

The `PageIndicatorSize` and `PageIndicatorVariant` types are also exported.

## Examples

### Sizes

The `sm` and `default` sizes.

![Preview](https://ui.suiss.app/previews/components-page-indicator--sizes.light.webp)

```tsx
import { View } from "react-native"
import { PageIndicator } from "@/components/ui/page-indicator"

export function PageIndicatorSizesExample() {
  return (
    <View className="items-center gap-6">
      <PageIndicator count={5} index={1} size="sm" />
      <PageIndicator count={5} index={1} />
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct PageIndicatorSizesExample: View {
  var body: some View {
    VStack(spacing: 24) {
      PageIndicator(count: 5, index: 1, size: .sm)
      PageIndicator(count: 5, index: 1)
    }
    .frame(maxWidth: .infinity)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.PageIndicator
import com.esuiss.ui.components.PageIndicatorSize

@Composable
fun PageIndicatorCentered(spacing: Int, content: @Composable () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(spacing.dp),
    ) {
        content()
    }
}

@Composable
fun PageIndicatorSizes() {
    PageIndicatorCentered(24) {
        PageIndicator(count = 5, index = 1, size = PageIndicatorSize.Sm)
        PageIndicator(count = 5, index = 1)
    }
}
```

### Accent

The `accent` variant, which colors the active dot with `bg-primary`.

![Preview](https://ui.suiss.app/previews/components-page-indicator--accent.light.webp)

```tsx
import { PageIndicator } from "@/components/ui/page-indicator"

export function PageIndicatorAccentExample() {
  return (
    <PageIndicator count={5} index={1} variant="accent" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct PageIndicatorAccentExample: View {
  var body: some View {
    PageIndicator(count: 5, index: 1, variant: .accent)
  }
}
```

```kotlin
import androidx.compose.runtime.Composable
import com.esuiss.ui.components.PageIndicator
import com.esuiss.ui.components.PageIndicatorVariant

@Composable
fun PageIndicatorAccentExample() {
    PageIndicator(count = 5, index = 1, variant = PageIndicatorVariant.Accent)
}
```

### Many pages

Twelve pages shown in a sliding window at the start, middle and end.

![Preview](https://ui.suiss.app/previews/components-page-indicator--many-pages.light.webp)

```tsx
import { View } from "react-native"
import { PageIndicator } from "@/components/ui/page-indicator"

export function PageIndicatorManyPagesExample() {
  return (
    <View className="items-center gap-6">
      <PageIndicator count={12} index={0} />
      <PageIndicator count={12} index={5} />
      <PageIndicator count={12} index={11} />
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct PageIndicatorManyPagesExample: View {
  var body: some View {
    VStack(spacing: 24) {
      PageIndicator(count: 12, index: 0)
      PageIndicator(count: 12, index: 5)
      PageIndicator(count: 12, index: 11)
    }
    .frame(maxWidth: .infinity)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.PageIndicator

@Composable
fun PageIndicatorCentered(spacing: Int, content: @Composable () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(spacing.dp),
    ) {
        content()
    }
}

@Composable
fun PageIndicatorManyPages() {
    PageIndicatorCentered(24) {
        PageIndicator(count = 12, index = 0)
        PageIndicator(count = 12, index = 5)
        PageIndicator(count = 12, index = 11)
    }
}
```

### Interactive

Pressable dots that change the page through `onIndexChange`.

![Preview](https://ui.suiss.app/previews/components-page-indicator--interactive.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { PageIndicator } from "@/components/ui/page-indicator"
import { Text } from "@/components/ui/text"

function InteractiveIndicator({ count }: { count: number }) {
  const [index, setIndex] = React.useState(0)
  return (
    <View className="items-center gap-3">
      <PageIndicator count={count} index={index} onIndexChange={setIndex} />
      <Text variant="muted">
        Page {index + 1} of {count}
      </Text>
    </View>
  )
}

export function PageIndicatorInteractiveExample() {
  return (
    <InteractiveIndicator count={12} />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct PageIndicatorInteractiveExample: View {
  var body: some View {
    PageIndicatorInteractive(count: 12)
  }
}

struct PageIndicatorInteractive: View {
  let count: Int

  @State private var index = 0

  var body: some View {
    VStack(spacing: 12) {
      PageIndicator(count: count, index: index) { index = $0 }
      Text(verbatim: "Page \(index + 1) of \(count)")
        .textVariant(.muted)
    }
    .frame(maxWidth: .infinity)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.PageIndicator
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant

@Composable
fun PageIndicatorInteractiveExample() {
    PageIndicatorInteractive(12)
}

@Composable
fun PageIndicatorCentered(spacing: Int, content: @Composable () -> Unit) {
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(spacing.dp),
    ) {
        content()
    }
}

@Composable
fun PageIndicatorInteractive(count: Int) {
    var index by rememberSaveable { mutableIntStateOf(0) }
    PageIndicatorCentered(12) {
        PageIndicator(count = count, index = index, onIndexChange = { index = it })
        Text("Page ${index + 1} of $count", variant = TextVariant.Muted)
    }
}
```

## API Reference

### PageIndicator

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `count` | `number` | Required | Total number of pages. Nothing renders when it is `0`. |
| `index` | `number` | Required | Zero-based index of the current page. |
| `onIndexChange` | `(index: number) => void` | - | Called with the new index when a dot is pressed or an accessibility action is used. |
| `size` | `PageIndicatorSize` | `"default"` | Dot size: `default` or `sm`. |
| `variant` | `PageIndicatorVariant` | `"default"` | Active dot color: `default` or `accent`. |
| `maxVisible` | `number` | `7` | Most dots shown at once. Defaults to `7`, minimum `3`. |

Also accepts the props of `ViewProps`.

## Accessibility

- The indicator is a single element with the `adjustable` role, and the dots are not focusable on their own.
- Its value is announced as "2 of 5".
- When `onIndexChange` is set, VoiceOver and TalkBack users swipe up or down to move to the next or previous page.
- Animations respect the system reduce motion setting.

## Notes

- Uses `bg-foreground` or `bg-primary` for the active dot and `bg-border` for the others; colors follow the system appearance.

