# Spinner

> A loading indicator component with multiple variants and customization options.

**BNA UI** — a React Native / Expo component library.
These components render through `react-native`, not the DOM: there are no HTML
elements, no Tailwind classes and no Radix primitives. Source is copied into your
project and imported through `@/components/ui/*`, `@/components/charts/*`,
`@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than
hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`)
come from `@/theme/globals`.

- Docs: https://ui.ahmedbna.com/docs/components/spinner
- Markdown: https://ui.ahmedbna.com/docs/components/spinner.md
- Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/spinner.json
- Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/spinner.json
- Install: `npx bna-ui add spinner`
- npm dependencies: `lucide-react-native`, `react-native-reanimated`, `react-native-worklets`
- Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`
- Preview recording: https://demo.ahmedbna.com/0279-spinner-demo.MP4

---

**Example:** A basic spinner with default styling

```tsx
// components/demo/spinner/spinner-demo.tsx
import { Spinner } from '@/components/ui/spinner';
import React from 'react';

export function SpinnerDemo() {
  return <Spinner size='default' variant='default' />;
}
```

## Installation

### CLI

```bash
npx bna-ui add spinner
```

### Manual

**1.** Install the following dependencies:

```bash
npx expo install lucide-react-native
```

**2.** Copy and paste the following code into your project.

```tsx
// components/ui/spinner.tsx
import { Text } from '@/components/ui/text';
import { useColor } from '@/hooks/useColor';
import { BORDER_RADIUS, CORNERS, FONT_SIZE } from '@/theme/globals';
import { Loader2 } from 'lucide-react-native';
import React, { useEffect, useMemo } from 'react';
import { ActivityIndicator, StyleSheet, View, ViewStyle } from 'react-native';
import Animated, {
  Easing,
  SharedValue,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withRepeat,
  withSequence,
  withTiming,
} from 'react-native-reanimated';

// Types
type SpinnerSize = 'default' | 'sm' | 'lg' | 'icon';
export type SpinnerVariant = 'default' | 'circle' | 'dots' | 'pulse' | 'bars';

interface SpinnerProps {
  size?: SpinnerSize;
  variant?: SpinnerVariant;
  label?: string;
  showLabel?: boolean;
  style?: ViewStyle;
  color?: string;
  thickness?: number; // Only affects the 'circle' variant's stroke width
  speed?: 'slow' | 'normal' | 'fast';
}

interface LoadingOverlayProps extends SpinnerProps {
  visible: boolean;
  backdrop?: boolean;
  backdropColor?: string;
  backdropOpacity?: number;
  onRequestClose?: () => void;
}

interface SpinnerConfig {
  size: number;
  iconSize: number;
  fontSize: number;
  gap: number;
  thickness: number;
}

// Configuration
const sizeConfig: Record<SpinnerSize, SpinnerConfig> = {
  sm: { size: 16, iconSize: 16, fontSize: 12, gap: 6, thickness: 2 },
  default: {
    size: 24,
    iconSize: 24,
    fontSize: FONT_SIZE,
    gap: 8,
    thickness: 2,
  },
  lg: { size: 32, iconSize: 32, fontSize: 16, gap: 10, thickness: 3 },
  icon: { size: 24, iconSize: 24, fontSize: FONT_SIZE, gap: 8, thickness: 2 },
};

const speedConfig = {
  slow: 1500,
  normal: 1000,
  fast: 500,
};

// --- Helper Animated Components for Dots and Bars ---

interface AnimatedShapeProps {
  anim: SharedValue<number>;
  color: string;
  size: number;
  style: ViewStyle;
}

const AnimatedDot = React.memo(
  ({ anim, color, size, style }: AnimatedShapeProps) => {
    const animatedStyle = useAnimatedStyle(() => ({
      opacity: anim.value,
    }));
    return (
      <Animated.View
        style={[
          style,
          { width: size, height: size, backgroundColor: color },
          animatedStyle,
        ]}
      />
    );
  }
);

const AnimatedBar = React.memo(
  ({ anim, color, size, style }: AnimatedShapeProps) => {
    const animatedStyle = useAnimatedStyle(() => ({
      opacity: anim.value,
    }));
    return (
      <Animated.View
        style={[
          style,
          { width: size / 6, height: size, backgroundColor: color },
          animatedStyle,
        ]}
      />
    );
  }
);

// Main Spinner Component
export function Spinner({
  size = 'default',
  variant = 'default',
  label,
  showLabel = false,
  style,
  color,
  thickness,
  speed = 'normal',
}: SpinnerProps) {
  // Reanimated shared values
  const rotate = useSharedValue(0);
  const pulse = useSharedValue(1);

  // --- FIX: Call hooks at the top level ---
  // 1. Call useSharedValue at the top level for each dot/bar
  const dotAnim1 = useSharedValue(0.3);
  const dotAnim2 = useSharedValue(0.3);
  const dotAnim3 = useSharedValue(0.3);

  const barAnim1 = useSharedValue(0.3);
  const barAnim2 = useSharedValue(0.3);
  const barAnim3 = useSharedValue(0.3);
  const barAnim4 = useSharedValue(0.3);

  // 2. Use useMemo to create a stable array reference from the values
  const dotsAnims = useMemo(
    () => [dotAnim1, dotAnim2, dotAnim3],
    [dotAnim1, dotAnim2, dotAnim3]
  );
  const barsAnims = useMemo(
    () => [barAnim1, barAnim2, barAnim3, barAnim4],
    [barAnim1, barAnim2, barAnim3, barAnim4]
  );
  // --- END FIX ---

  // Theme colors
  const primaryColor = useColor('text');
  const textColor = useColor('text');

  const config = sizeConfig[size];
  const spinnerColor = color || primaryColor;
  const animationDuration = speedConfig[speed];

  // Rotation animation
  useEffect(() => {
    if (variant === 'circle') {
      rotate.value = withRepeat(
        withTiming(360, { duration: animationDuration, easing: Easing.linear }),
        -1
      );
    } else {
      rotate.value = 0; // Reset
    }
  }, [rotate, variant, animationDuration]);

  // Pulse animation
  useEffect(() => {
    if (variant === 'pulse') {
      pulse.value = withRepeat(
        withSequence(
          withTiming(1.3, { duration: animationDuration / 2 }),
          withTiming(1, { duration: animationDuration / 2 })
        ),
        -1,
        true
      );
    } else {
      pulse.value = 1; // Reset
    }
  }, [pulse, variant, animationDuration]);

  // Dots animation
  useEffect(() => {
    if (variant === 'dots') {
      dotsAnims.forEach((anim, index) => {
        anim.value = withRepeat(
          withSequence(
            withDelay(
              index * (animationDuration / 6),
              withTiming(1, { duration: animationDuration / 3 })
            ),
            withTiming(0.3, { duration: animationDuration / 3 })
          ),
          -1
        );
      });
    } else {
      dotsAnims.forEach((anim) => (anim.value = 0.3)); // Reset
    }
  }, [dotsAnims, variant, animationDuration]);

  // Bars animation
  useEffect(() => {
    if (variant === 'bars') {
      barsAnims.forEach((anim, index) => {
        anim.value = withRepeat(
          withSequence(
            withDelay(
              index * (animationDuration / 8),
              withTiming(1, { duration: animationDuration / 4 })
            ),
            withTiming(0.3, { duration: animationDuration / 4 })
          ),
          -1
        );
      });
    } else {
      barsAnims.forEach((anim) => (anim.value = 0.3)); // Reset
    }
  }, [barsAnims, variant, animationDuration]);

  // Animated styles
  const animatedCircleStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${rotate.value}deg` }],
  }));

  const animatedPulseStyle = useAnimatedStyle(() => ({
    transform: [{ scale: pulse.value }],
  }));

  const renderSpinner = () => {
    switch (variant) {
      case 'default':
        return (
          <ActivityIndicator
            size={config.size}
            color={spinnerColor}
            style={styles.spinner}
          />
        );

      case 'circle':
        return (
          <Animated.View
            style={[
              styles.customSpinner,
              { width: config.size, height: config.size },
              animatedCircleStyle,
            ]}
          >
            <Loader2
              size={config.iconSize}
              color={spinnerColor}
              strokeWidth={thickness ?? config.thickness}
            />
          </Animated.View>
        );

      case 'pulse':
        return (
          <Animated.View
            style={[
              styles.pulseSpinner,
              {
                width: config.size,
                height: config.size,
                backgroundColor: spinnerColor,
              },
              animatedPulseStyle,
            ]}
          />
        );

      case 'dots':
        return (
          <View style={[styles.dotsContainer, { gap: config.size / 4 }]}>
            {dotsAnims.map((anim, index) => (
              <AnimatedDot
                key={index}
                anim={anim}
                color={spinnerColor}
                size={config.size / 3}
                style={styles.dot}
              />
            ))}
          </View>
        );

      case 'bars':
        return (
          <View style={[styles.barsContainer, { gap: config.size / 6 }]}>
            {barsAnims.map((anim, index) => (
              <AnimatedBar
                key={index}
                anim={anim}
                color={spinnerColor}
                size={config.size}
                style={styles.bar}
              />
            ))}
          </View>
        );

      default:
        return null;
    }
  };

  const containerStyle: ViewStyle = {
    alignItems: 'center',
    justifyContent: 'center',
    gap: config.gap,
  };

  return (
    <View
      style={[containerStyle, style]}
      accessibilityRole='progressbar'
      accessibilityLabel={label || 'Loading'}
    >
      {renderSpinner()}
      {(showLabel || label) && (
        <Text
          style={[
            styles.label,
            {
              color: textColor,
              fontSize: config.fontSize,
            },
          ]}
        >
          {label || 'Loading...'}
        </Text>
      )}
    </View>
  );
}

// Loading Overlay Component
export function LoadingOverlay({
  visible,
  backdrop = true,
  backdropColor,
  backdropOpacity = 0.5,
  ...spinnerProps
}: LoadingOverlayProps) {
  const opacity = useSharedValue(0);
  const backgroundColor = useColor('background');
  const cardColor = useColor('card');

  useEffect(() => {
    opacity.value = withTiming(visible ? 1 : 0, {
      duration: 200,
    });
  }, [visible, opacity]);

  const animatedOverlayStyle = useAnimatedStyle(() => ({
    opacity: opacity.value,
    // Conditionally render to avoid interaction issues
    display: opacity.value === 0 ? 'none' : 'flex',
  }));

  const defaultBackdropColor =
    backdropColor ||
    `${backgroundColor}${Math.round(backdropOpacity * 255)
      .toString(16)
      .padStart(2, '0')}`;

  return (
    <Animated.View
      style={[
        styles.overlay,
        { backgroundColor: backdrop ? defaultBackdropColor : 'transparent' },
        animatedOverlayStyle,
      ]}
      pointerEvents={visible ? 'auto' : 'none'}
    >
      <View style={[styles.overlayContent, { backgroundColor: cardColor }]}>
        <Spinner {...spinnerProps} />
      </View>
    </Animated.View>
  );
}

// Inline Loader Component (for buttons, etc.)
export function InlineLoader({
  size = 'sm',
  variant = 'default',
  color,
}: Omit<SpinnerProps, 'label' | 'showLabel'>) {
  return (
    <Spinner
      size={size}
      variant={variant}
      color={color}
      style={styles.inlineLoader}
    />
  );
}

// Button Spinner Component - optimized for button usage
export function ButtonSpinner({
  size = 'sm',
  variant = 'default',
  color,
}: Omit<SpinnerProps, 'label' | 'showLabel'>) {
  const primaryForegroundColor = useColor('primaryForeground');

  return (
    <Spinner
      size={size}
      variant={variant}
      color={color || primaryForegroundColor}
      style={styles.buttonSpinner}
    />
  );
}

const styles = StyleSheet.create({
  spinner: {
    alignSelf: 'center',
  },
  customSpinner: {
    alignItems: 'center',
    justifyContent: 'center',
  },
  pulseSpinner: {
    borderRadius: 999,
  },
  dotsContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
  },
  dot: {
    borderRadius: 999,
  },
  barsContainer: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
  },
  bar: {
    borderRadius: CORNERS,
  },
  label: {
    textAlign: 'center',
    fontWeight: '500',
  },
  overlay: {
    ...StyleSheet.absoluteFill,
    alignItems: 'center',
    justifyContent: 'center',
    zIndex: 9999,
  },
  overlayContent: {
    padding: 60,
    borderRadius: BORDER_RADIUS,
  },
  inlineLoader: {
    minHeight: 0,
    minWidth: 0,
  },
  buttonSpinner: {
    minHeight: 0,
    minWidth: 0,
  },
});
```

**3.** Update the import paths to match your project setup.

## Usage

```tsx
import {
  Spinner,
  LoadingOverlay,
  InlineLoader,
  ButtonSpinner,
} from '@/components/ui/spinner';
```

```tsx
<Spinner size='default' variant='default' />
```

## Examples

#### Default

**Example:** A basic spinner with default styling

```tsx
// components/demo/spinner/spinner-demo.tsx
import { Spinner } from '@/components/ui/spinner';
import React from 'react';

export function SpinnerDemo() {
  return <Spinner size='default' variant='default' />;
}
```

#### Variants

**Example:** Different spinner variants: default, circle, dots, pulse, and bars

```tsx
// components/demo/spinner/spinner-variants.tsx
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React from 'react';

export function SpinnerVariants() {
  const variants = [
    { variant: 'default' as const, label: 'Default' },
    { variant: 'circle' as const, label: 'Circle' },
    { variant: 'dots' as const, label: 'Dots' },
    { variant: 'pulse' as const, label: 'Pulse' },
    { variant: 'bars' as const, label: 'Bars' },
  ];

  return (
    <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 24 }}>
      {variants.map(({ variant, label }) => (
        <View key={variant} style={{ alignItems: 'center', gap: 8 }}>
          <Spinner variant={variant} size='default' />
          <Text variant='caption' style={{ textAlign: 'center' }}>
            {label}
          </Text>
        </View>
      ))}
    </View>
  );
}
```

#### Sizes

**Example:** Spinners in different sizes: sm, default, lg, and icon

```tsx
// components/demo/spinner/spinner-sizes.tsx
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React from 'react';

export function SpinnerSizes() {
  const sizes = [
    { size: 'sm' as const, label: 'Small' },
    { size: 'default' as const, label: 'Default' },
    { size: 'lg' as const, label: 'Large' },
    { size: 'icon' as const, label: 'Icon' },
  ];

  return (
    <View style={{ flexDirection: 'row', alignItems: 'center', gap: 32 }}>
      {sizes.map(({ size, label }) => (
        <View key={size} style={{ alignItems: 'center', gap: 8 }}>
          <Spinner size={size} variant='circle' />
          <Text variant='caption' style={{ textAlign: 'center' }}>
            {label}
          </Text>
        </View>
      ))}
    </View>
  );
}
```

#### With Labels

**Example:** Spinners with custom loading labels

```tsx
// components/demo/spinner/spinner-labels.tsx
import { Spinner } from '@/components/ui/spinner';
import { View } from '@/components/ui/view';
import React from 'react';

export function SpinnerLabels() {
  return (
    <View style={{ gap: 24 }}>
      <Spinner size='default' variant='default' showLabel />
      <Spinner size='default' variant='dots' label='Processing...' />
      <Spinner size='default' variant='pulse' label='Uploading files...' />
      <Spinner size='lg' variant='circle' label='Please wait' />
    </View>
  );
}
```

#### Speed Control

**Example:** Spinners with different animation speeds: slow, normal, and fast

```tsx
// components/demo/spinner/spinner-speeds.tsx
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React from 'react';

export function SpinnerSpeeds() {
  const speeds = [
    { speed: 'slow' as const, label: 'Slow' },
    { speed: 'normal' as const, label: 'Normal' },
    { speed: 'fast' as const, label: 'Fast' },
  ];

  return (
    <View style={{ flexDirection: 'row', gap: 32 }}>
      {speeds.map(({ speed, label }) => (
        <View key={speed} style={{ alignItems: 'center', gap: 8 }}>
          <Spinner variant='circle' size='default' speed={speed} />
          <Text variant='caption' style={{ textAlign: 'center' }}>
            {label}
          </Text>
        </View>
      ))}
    </View>
  );
}
```

#### Custom Colors

**Example:** Spinners with custom colors and styling

```tsx
// components/demo/spinner/spinner-colors.tsx
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React from 'react';

export function SpinnerColors() {
  const colors = [
    { color: '#3b82f6', label: 'Blue', variant: 'default' as const },
    { color: '#10b981', label: 'Green', variant: 'dots' as const },
    { color: '#f59e0b', label: 'Orange', variant: 'pulse' as const },
    { color: '#ef4444', label: 'Red', variant: 'bars' as const },
    { color: '#8b5cf6', label: 'Purple', variant: 'circle' as const },
  ];

  return (
    <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 24 }}>
      {colors.map(({ color, label, variant }) => (
        <View key={color} style={{ alignItems: 'center', gap: 8 }}>
          <Spinner variant={variant} size='default' color={color} />
          <Text variant='caption' style={{ textAlign: 'center' }}>
            {label}
          </Text>
        </View>
      ))}
    </View>
  );
}
```

#### Loading Overlay

**Example:** Full-screen loading overlay with backdrop

```tsx
// components/demo/spinner/spinner-overlay.tsx
import { Button } from '@/components/ui/button';
import { LoadingOverlay } from '@/components/ui/spinner';
import { View } from '@/components/ui/view';
import React, { useState } from 'react';

export function SpinnerOverlay() {
  const [showOverlay, setShowOverlay] = useState(false);

  const handleShowOverlay = () => {
    setShowOverlay(true);
    // Auto hide after 3 seconds for demo
    setTimeout(() => setShowOverlay(false), 3000);
  };

  return (
    <View style={{ gap: 16 }}>
      <Button onPress={handleShowOverlay} disabled={showOverlay}>
        Show Loading Overlay
      </Button>

      <LoadingOverlay
        visible={showOverlay}
        size='lg'
        variant='circle'
        label='Loading content...'
        backdrop={true}
        backdropOpacity={0.7}
      />
    </View>
  );
}
```

#### Inline Loader

**Example:** Small spinners for inline usage in buttons or text

```tsx
// components/demo/spinner/spinner-inline.tsx
import { InlineLoader } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { View } from '@/components/ui/view';
import React from 'react';

export function SpinnerInline() {
  return (
    <View style={{ gap: 16 }}>
      <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
        <Text>Loading data</Text>
        <InlineLoader size='sm' variant='default' />
      </View>

      <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
        <Text>Processing</Text>
        <InlineLoader size='sm' variant='dots' />
      </View>

      <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
        <InlineLoader size='sm' variant='pulse' color='#10b981' />
        <Text>Syncing...</Text>
      </View>
    </View>
  );
}
```

## API Reference

### Spinner

The main spinner component with multiple variants and customization options.

| Prop        | Type                                                   | Default     | Description                                                                               |
| ----------- | ------------------------------------------------------ | ----------- | ----------------------------------------------------------------------------------------- |
| `size`      | `'default' \| 'sm' \| 'lg' \| 'icon'`                  | `'default'` | The size of the spinner.                                                                  |
| `variant`   | `'default' \| 'circle' \| 'dots' \| 'pulse' \| 'bars'` | `'default'` | The visual variant of the spinner.                                                        |
| `label`     | `string`                                               | -           | Optional label text to display with spinner.                                              |
| `showLabel` | `boolean`                                              | `false`     | Whether to show the default "Loading..." label.                                           |
| `style`     | `ViewStyle`                                            | -           | Additional styles to apply to the container.                                              |
| `color`     | `string`                                               | -           | Custom color for the spinner.                                                             |
| `speed`     | `'slow' \| 'normal' \| 'fast'`                         | `'normal'`  | Animation speed of the spinner.                                                           |
| `thickness` | `number`                                               | -           | Stroke width of the icon used by the `'circle'` variant. Has no effect on other variants. |

### LoadingOverlay

A full-screen overlay component with spinner for blocking UI interactions during loading.

| Prop              | Type           | Default | Description                                    |
| ----------------- | -------------- | ------- | ---------------------------------------------- |
| `visible`         | `boolean`      | -       | Whether the overlay is visible.                |
| `backdrop`        | `boolean`      | `true`  | Whether to show a backdrop behind the spinner. |
| `backdropColor`   | `string`       | -       | Custom backdrop color.                         |
| `backdropOpacity` | `number`       | `0.5`   | Opacity of the backdrop (0-1).                 |
| `onRequestClose`  | `() => void`   | -       | Callback when overlay should be closed.        |
| `...spinnerProps` | `SpinnerProps` | -       | All props from the Spinner component.          |

### InlineLoader

A compact spinner optimized for inline usage within text or small containers.

| Prop      | Type                                                   | Default     | Description                        |
| --------- | ------------------------------------------------------ | ----------- | ---------------------------------- |
| `size`    | `'default' \| 'sm' \| 'lg' \| 'icon'`                  | `'sm'`      | The size of the spinner.           |
| `variant` | `'default' \| 'circle' \| 'dots' \| 'pulse' \| 'bars'` | `'default'` | The visual variant of the spinner. |
| `color`   | `string`                                               | -           | Custom color for the spinner.      |

### ButtonSpinner

A spinner component specifically designed for button loading states.

| Prop      | Type                                                   | Default     | Description                        |
| --------- | ------------------------------------------------------ | ----------- | ---------------------------------- |
| `size`    | `'default' \| 'sm' \| 'lg' \| 'icon'`                  | `'sm'`      | The size of the spinner.           |
| `variant` | `'default' \| 'circle' \| 'dots' \| 'pulse' \| 'bars'` | `'default'` | The visual variant of the spinner. |
| `color`   | `string`                                               | -           | Custom color for the spinner.      |

## Accessibility

The Spinner component is built with accessibility in mind:

- Provides meaningful loading states for screen readers
- Supports custom labels for better context
- Maintains proper contrast ratios for visibility
- Non-intrusive animations that respect user preferences
- Loading overlays properly manage focus and interaction states
