suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Components82
  • Overview
  • Accordion
  • Alert
  • Alert Dialog
  • Alert Sheet
  • Amount Input
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Bottom Bar
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Chip
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Direction
  • Drawer
  • Dropdown Menu
  • Empty
  • FAB
  • Field
  • Fullscreen Menu
  • Haptics
  • Header
  • Hover Card
  • Icon
  • Input
  • Input Accessory Bar
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Keyboard Scroll View
  • Keypad
  • Label
  • Marker
  • Menubar
  • Message
  • Message Scroller
  • Native Select
  • Navigation Menu
  • Page Indicator
  • Pagination
  • Pin Pad
  • Popover
  • Progress
  • QR Scanner
  • Questionnaire
  • Radio Group
  • Resizable
  • Screen
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Signature Pad
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Tab Bar
  • Table
  • Tabs
  • Text
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Wheel Picker
  • Widget

suiss/ui · Component

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.

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

$ pnpm dlx shadcn@latest add @esuiss/spinner

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add spinner

Manual

  1. Add the cn helper.

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

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(
  1. Update the import paths to match your project.

Usage

import { Spinner } from "@/components/ui/spinner"
<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.

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.

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.

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

01

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.
iOS
Spinner on iOS in light modeSpinner on iOS in dark mode
import { Spinner } from "@/components/ui/spinner"

export function SpinnerExample() {
  return (
    <Spinner />
  )
}
import EsuissUI
import SwiftUI

struct SpinnerExample: View {
  var body: some View {
    Spinner()
      .foregroundStyle(EsuissColor.mutedForeground)
  }
}
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

$ pnpm dlx shadcn@latest add @esuiss-rn/spinner

See Installation for the components.json setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add spinner
$ pnpm dlx @esuiss/ui@latest add spinner --platform swift
$ pnpm dlx @esuiss/ui@latest add spinner --platform kotlin

Manual

  1. Install the dependencies:
npx expo install cn
  1. Copy the source into components/ui/spinner.tsx:
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
}) {
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn nativewind
  1. Copy the source into components/ui/spinner.tsx:
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",
  1. Update the import paths to match your project.
  1. Copy the source into components/ui/spinner.tsx:
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 (
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Copy the theme files every view uses:

swift/Theme/Theme.swift
import SwiftUI
import UIKit

struct RGBA: Sendable {
  let red: CGFloat
  let green: CGFloat
  let blue: CGFloat
  let alpha: CGFloat

  init(_ hex: UInt32, _ alpha: CGFloat = 1) {
    red = CGFloat((hex >> 16) & 0xFF) / 255
    green = CGFloat((hex >> 8) & 0xFF) / 255
    blue = CGFloat(hex & 0xFF) / 255
    self.alpha = alpha
  }
swift/Theme/Tokens.swift
import SwiftUI

public enum EsuissColor {
  public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF))
  public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  1. Copy the source:
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
      }
    }
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
build.gradle.kts
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
kotlin/theme/Tokens.kt
package com.esuiss.ui.theme

import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Immutable
data class Colors(
    val background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
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
kotlin/components/Content.kt
package com.esuiss.ui.components

import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
kotlin/components/FormSupport.kt
package com.esuiss.ui.components

import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { Spinner } from "@/components/ui/spinner"
<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.

iOS
Spinner (sizes) on iOS in light modeSpinner (sizes) on iOS in dark mode
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>
  )
}
import EsuissUI
import SwiftUI

struct SpinnerSizesExample: View {
  var body: some View {
    HStack(spacing: 24) {
      Spinner(.sm)
      Spinner()
      Spinner(.lg)
      Spinner(.xl)
    }
    .foregroundStyle(EsuissColor.mutedForeground)
  }
}
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.

iOS
Spinner (colors) on iOS in light modeSpinner (colors) on iOS in dark mode
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>
  )
}
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)
    }
  }
}
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.

iOS
Spinner (in button) on iOS in light modeSpinner (in button) on iOS in dark mode
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>
  )
}
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)
  }
}
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

01

Spinner

PropTypeDefaultDescription
sizenumber16Width and height in points. Defaults to 16.
colorClassNamestring"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.
NextSwitchPreviousSlider

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • Sizes
    • Colors
    • In button
  4. 04API Reference
    • Spinner
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Sizes
    • Colors
    • In button
  10. 10API Reference
    • Spinner
  11. 11Accessibility
  12. 12Notes