# Spinner

An eight-spoke activity indicator that shows an indeterminate wait.

Spinner is an open-source component from suiss/ui for React, 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/spinner

**React**

**Example**

```tsx
import { Spinner } from "@/components/ui/spinner"

export default function SpinnerDemo() {
  return <Spinner />
}
```

`Spinner` is an SVG of eight fading spokes that rotates in steps, like the system activity indicator. It draws in `currentColor` and is `size-4` by default, so it inherits the surrounding text color.

Use it for short waits whose length is unknown, such as saving or loading more. For a placeholder that previews the shape of content, use `Skeleton`.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/spinner
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add spinner
```

### Manual

1. Add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper).

2. Copy the source into `components/ui/spinner.tsx`:

```tsx title="components/ui/spinner.tsx"
import { cn } from "@/lib/utils"

const spokes = Array.from({ length: 8 }, (_, index) => ({
  angle: index * 45,
  opacity: Number((0.2 + (0.8 * index) / 7).toFixed(2)),
}))

function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
  return (
    <svg
      data-slot="spinner"
      role="status"
      aria-label="Loading"
      viewBox="0 0 24 24"
      fill="currentColor"
      className={cn(
        "size-4 shrink-0 animate-[spin_0.8s_steps(8)_infinite]",
        className
      )}
      {...props}
    >
      {spokes.map(({ angle, opacity }) => (
        <rect
          key={angle}
          x="10.44"
          y="0.5"
          width="3.12"
          height="7.2"
          rx="1.56"
          opacity={opacity}
          transform={`rotate(${angle} 12 12)`}
        />
      ))}
    </svg>
  )
}

export { Spinner }
```

3. Update the import paths to match your project.

## Usage

```tsx
import { Spinner } from "@/components/ui/spinner"
```

```tsx
<Spinner />
```

Change the size with a `size-*` class and the color with a `text-*` class. Inside a `Button`, pair it with `disabled` and give it `data-icon="inline-start"` to place it before the label.

## Examples

### Sizes

Shows spinners from `size-3` to `size-8`.

**Example**

```tsx
import { Spinner } from "@/components/ui/spinner"

export default function SpinnerSizes() {
  return (
    <div className="flex items-center gap-4">
      <Spinner className="size-3" />
      <Spinner className="size-4" />
      <Spinner className="size-6" />
      <Spinner className="size-8" />
    </div>
  )
}
```

### Colors

Tints spinners with `text-*` color classes.

**Example**

```tsx
import { Spinner } from "@/components/ui/spinner"

export default function SpinnerColors() {
  return (
    <div className="flex items-center gap-4">
      <Spinner className="text-primary size-6" />
      <Spinner className="text-muted-foreground size-6" />
      <Spinner className="text-destructive size-6" />
    </div>
  )
}
```

### In button

Places a spinner in disabled buttons that are busy.

**Example**

```tsx
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"

export default function SpinnerInButton() {
  return (
    <div className="flex items-center gap-3">
      <Button disabled>
        <Spinner data-icon="inline-start" />
        Saving
      </Button>
      <Button variant="outline" disabled>
        <Spinner data-icon="inline-start" />
        Loading more
      </Button>
    </div>
  )
}
```

## API Reference

### Spinner

The animated activity indicator.

Also accepts the props of `React.ComponentProps<"svg">`.

## Accessibility

- Renders with the `status` role and the accessible name `Loading`. Override `aria-label` to describe the specific task.

## Notes

- Uses `data-slot="spinner"`.
- The rotation lasts `0.8s` and moves in eight steps, so one spoke advances at a time.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx
import { Spinner } from "@/components/ui/spinner"

export function SpinnerExample() {
  return (
    <Spinner />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SpinnerExample: View {
  var body: some View {
    Spinner()
      .foregroundStyle(EsuissColor.mutedForeground)
  }
}
```

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

@Composable
fun SpinnerExample() {
    Spinner()
}
```

`Spinner` wraps the native `ActivityIndicator` in a square `View`. The indicator is scaled from its native small size to the `size` you pass, so it can match text and icon sizes. Its color is set through a class.

## Installation

**React Native**

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

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 spinner
```

**SwiftUI**

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

**Jetpack Compose**

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

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Copy the source into `components/ui/spinner.tsx`:

```tsx title="uniwind/components/ui/spinner.tsx"
import { cn } from "cn"
import { ActivityIndicator, View } from "react-native"

const NATIVE_SIZE = 20

function Spinner({
  colorClassName = "accent-muted-foreground",
  className,
  style,
  size = 16,
  accessibilityLabel = "Loading",
  ...props
}: Omit<React.ComponentProps<typeof ActivityIndicator>, "size"> & {
  size?: number
  colorClassName?: string
}) {
  return (
    <View
      role="progressbar"
      accessible
      accessibilityLabel={accessibilityLabel}
      accessibilityState={{ busy: true }}
      className={cn("shrink-0 items-center justify-center", className)}
      style={[{ width: size, height: size }, style]}
    >
      <ActivityIndicator
        colorClassName={colorClassName}
        size="small"
        style={{ transform: [{ scale: size / NATIVE_SIZE }] }}
        {...props}
      />
    </View>
  )
}

export { Spinner }
```

3. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn nativewind
```

2. Copy the source into `components/ui/spinner.tsx`:

```tsx title="nativewind/components/ui/spinner.tsx"
import { cn } from "cn"
import { cssInterop } from "nativewind"
import { ActivityIndicator, View } from "react-native"

cssInterop(ActivityIndicator, {
  className: { target: "style", nativeStyleToProp: { color: true } },
})

const NATIVE_SIZE = 20

function Spinner({
  colorClassName = "text-muted-foreground",
  className,
  style,
  size = 16,
  accessibilityLabel = "Loading",
  ...props
}: Omit<React.ComponentProps<typeof ActivityIndicator>, "size"> & {
  size?: number
  colorClassName?: string
}) {
  return (
    <View
      role="progressbar"
      accessible
      accessibilityLabel={accessibilityLabel}
      accessibilityState={{ busy: true }}
      className={cn("shrink-0 items-center justify-center", className)}
      style={[{ width: size, height: size }, style]}
    >
      <ActivityIndicator
        className={colorClassName}
        size="small"
        style={{ transform: [{ scale: size / NATIVE_SIZE }] }}
        {...props}
      />
    </View>
  )
}

export { Spinner }
```

3. Update the import paths to match your project.

**StyleSheet**

1. Copy the source into `components/ui/spinner.tsx`:

```tsx title="stylesheet/components/ui/spinner.tsx"
import { ActivityIndicator, View } from "react-native"
import { useTheme } from "@/components/ui/theme"

const NATIVE_SIZE = 20

function Spinner({
  color,
  style,
  size = 16,
  accessibilityLabel = "Loading",
  ...props
}: Omit<React.ComponentProps<typeof ActivityIndicator>, "size"> & {
  size?: number
}) {
  const { colors } = useTheme()
  return (
    <View
      role="progressbar"
      accessible
      accessibilityLabel={accessibilityLabel}
      accessibilityState={{ busy: true }}
      style={[
        {
          width: size,
          height: size,
          flexShrink: 0,
          alignItems: "center",
          justifyContent: "center",
        },
        style,
      ]}
    >
      <ActivityIndicator
        color={color ?? colors.mutedForeground}
        size="small"
        style={{ transform: [{ scale: size / NATIVE_SIZE }] }}
        {...props}
      />
    </View>
  )
}

export { Spinner }
```

2. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. 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)

3. Copy the source:

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

public struct Spinner: View {
  public enum Size: CaseIterable, Sendable {
    case sm, `default`, lg, xl

    public var value: CGFloat {
      switch self {
      case .sm: 12
      case .default: 16
      case .lg: 24
      case .xl: 32
      }
    }
  }

  let dimension: CGFloat
  let label: Text

  public init(_ size: Size = .default, label: LocalizedStringKey = "Loading") {
    dimension = size.value
    self.label = Text(label)
  }

  public init(dimension: CGFloat, label: LocalizedStringKey = "Loading") {
    self.dimension = dimension
    self.label = Text(label)
  }

  public var body: some View {
    TimelineView(.periodic(from: .now, by: 0.1)) { context in
      let step = Int((context.date.timeIntervalSinceReferenceDate * 10).rounded(.down)) % 8
      SpinnerSpokes(dimension: dimension, progress: nil)
        .rotationEffect(.degrees(Double(step) * 45))
    }
    .frame(width: dimension, height: dimension)
    .accessibilityElement()
    .accessibilityLabel(label)
    .accessibilityAddTraits(.updatesFrequently)
  }
}

struct SpinnerSpokes: View {
  let dimension: CGFloat
  let progress: Double?

  var body: some View {
    let unit = dimension / 24
    ZStack {
      ForEach(0..<8, id: \.self) { index in
        Capsule()
          .fill(.foreground)
          .frame(width: 3.12 * unit, height: 7.2 * unit)
          .offset(y: -7.9 * unit)
          .rotationEffect(.degrees(Double(index) * 45))
          .opacity(opacity(index))
      }
    }
    .frame(width: dimension, height: dimension)
  }

  private func opacity(_ index: Int) -> Double {
    guard let progress else { return 0.2 + 0.8 * Double(index) / 7 }
    return min(max(progress * 8 - Double(index), 0), 1)
  }
}
```

4. 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")
```

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/Spinner.kt"
package com.esuiss.ui.components

import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import kotlin.math.floor

internal const val SpinnerSpokes = 8

internal fun spinnerSpokeOpacity(index: Int): Float = 0.2f + 0.8f * index / (SpinnerSpokes - 1)

internal fun DrawScope.drawSpinnerSpokes(color: Color, rotation: Float = 0f, opacity: (Int) -> Float) {
    val unit = size.minDimension / 24f
    val center = Offset(size.width / 2f, size.height / 2f)
    rotate(rotation, center) {
        for (index in 0 until SpinnerSpokes) {
            val alpha = opacity(index).coerceIn(0f, 1f)
            if (alpha <= 0f) continue
            rotate(index * 360f / SpinnerSpokes, center) {
                drawRoundRect(
                    color = color,
                    topLeft = Offset(center.x - 1.56f * unit, center.y - 11.5f * unit),
                    size = Size(3.12f * unit, 7.2f * unit),
                    cornerRadius = CornerRadius(1.56f * unit),
                    alpha = alpha * color.alpha,
                )
            }
        }
    }
}

@Composable
fun Spinner(
    modifier: Modifier = Modifier,
    size: Dp = 16.dp,
    color: Color = Color.Unspecified,
    contentDescription: String = "Loading",
) {
    val tint = color.takeOrElse { LocalContentColor.current.takeOrElse { EsuissTheme.colors.foreground } }
    val reduced = formReducedMotion()
    val step: State<Float> = if (reduced) {
        remember { mutableFloatStateOf(0f) }
    } else {
        rememberInfiniteTransition(label = "spinner").animateFloat(
            initialValue = 0f,
            targetValue = SpinnerSpokes.toFloat(),
            animationSpec = infiniteRepeatable(tween(800, easing = LinearEasing), RepeatMode.Restart),
            label = "step",
        )
    }
    Canvas(
        modifier = modifier
            .size(size)
            .semantics {
                this.contentDescription = contentDescription
                progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate
                liveRegion = LiveRegionMode.Polite
            },
    ) {
        val rotation = (floor(step.value) % SpinnerSpokes) * 360f / SpinnerSpokes
        drawSpinnerSpokes(tint, rotation) { spinnerSpokeOpacity(it) }
    }
}
```

- `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/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

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

## Usage

```tsx
import { Spinner } from "@/components/ui/spinner"
```

```tsx
<Spinner />
```

Set `size` in points (default `16`) and `colorClassName` with an `accent-*` class, such as `accent-primary` or `accent-destructive`. Inside a button, use `accent-primary-foreground` on the `default` variant so the spinner matches the label.

## Examples

### Sizes

Spinners at 12, 16, 24 and 32 points.

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

```tsx
import { View } from "react-native"
import { Spinner } from "@/components/ui/spinner"

export function SpinnerSizesExample() {
  return (
    <View className="flex-row items-center gap-6">
      <Spinner size={12} />
      <Spinner />
      <Spinner size={24} />
      <Spinner size={32} />
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SpinnerSizesExample: View {
  var body: some View {
    HStack(spacing: 24) {
      Spinner(.sm)
      Spinner()
      Spinner(.lg)
      Spinner(.xl)
    }
    .foregroundStyle(EsuissColor.mutedForeground)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Spinner

@Composable
fun SpinnerRow(content: @Composable () -> Unit) {
    Row(horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically) {
        content()
    }
}

@Composable
fun SpinnerSizes() {
    SpinnerRow {
        Spinner(size = 12.dp)
        Spinner()
        Spinner(size = 24.dp)
        Spinner(size = 32.dp)
    }
}
```

### Colors

Spinners in the foreground, primary and destructive colors.

![Preview](https://ui.suiss.app/previews/components-spinner--colors.light.webp)

```tsx
import { View } from "react-native"
import { Spinner } from "@/components/ui/spinner"

export function SpinnerColorsExample() {
  return (
    <View className="flex-row items-center gap-6">
      <Spinner size={24} colorClassName="accent-foreground" />
      <Spinner size={24} colorClassName="accent-primary" />
      <Spinner size={24} colorClassName="accent-destructive" />
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SpinnerColorsExample: View {
  var body: some View {
    HStack(spacing: 24) {
      Spinner(.lg).foregroundStyle(EsuissColor.foreground)
      Spinner(.lg).foregroundStyle(EsuissColor.primary)
      Spinner(.lg).foregroundStyle(EsuissColor.destructive)
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Spinner
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun SpinnerRow(content: @Composable () -> Unit) {
    Row(horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically) {
        content()
    }
}

@Composable
fun SpinnerColors() {
    val colors = EsuissTheme.colors
    SpinnerRow {
        Spinner(size = 24.dp, color = colors.foreground)
        Spinner(size = 24.dp, color = colors.primary)
        Spinner(size = 24.dp, color = colors.destructive)
    }
}
```

### In button

Spinners inside disabled buttons next to a label.

![Preview](https://ui.suiss.app/previews/components-spinner--in-button.light.webp)

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { Text } from "@/components/ui/text"

export function SpinnerInButtonExample() {
  return (
    <View className="items-start gap-3">
      <Button disabled>
        <Spinner colorClassName="accent-primary-foreground" />
        <Text>Saving</Text>
      </Button>
      <Button variant="secondary" disabled>
        <Spinner />
        <Text>Loading</Text>
      </Button>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SpinnerInButtonExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      Button {} label: {
        HStack(spacing: 6) {
          Spinner().foregroundStyle(EsuissColor.primaryForeground)
          Text("Saving")
        }
      }
      .buttonStyle(.variant())
      Button {} label: {
        HStack(spacing: 6) {
          Spinner().foregroundStyle(EsuissColor.mutedForeground)
          Text("Loading")
        }
      }
      .buttonStyle(.variant(.secondary))
    }
    .disabled(true)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Spinner
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun SpinnerInButton() {
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Button(onClick = {}, enabled = false) {
            Spinner(color = EsuissTheme.colors.primaryForeground)
            Text("Saving")
        }
        Button(onClick = {}, variant = ButtonVariant.Secondary, enabled = false) {
            Spinner()
            Text("Loading")
        }
    }
}
```

## API Reference

### Spinner

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `number` | `16` | Width and height in points. Defaults to `16`. |
| `colorClassName` | `string` | `"accent-muted-foreground"` | Class that sets the indicator color. Defaults to `accent-muted-foreground`. |

Also accepts the props of `React.ComponentProps<typeof ActivityIndicator>`.

## Accessibility

- The wrapper has `role="progressbar"`, is `accessible`, and sets `busy: true` in `accessibilityState`.
- It is announced as `"Loading"` unless you pass another `accessibilityLabel`.

## Notes

- The default color uses the `muted-foreground` token.
- The look of the indicator comes from the platform's native activity indicator, so it differs between iOS and Android.

