# Spinner

> An indeterminate loading indicator in a Colour Role, announced as busy; still under Reduce Motion.

Component · feedback. Registry item `spinner`, shown in the vega Style. JSON: https://nativecn.dev/r/styles/vega/spinner.json

## Installation

```sh
npx nativecn-cli@latest add spinner
```

Also installs: [theme](https://nativecn.dev/docs/theme/theme), [icon](https://nativecn.dev/docs/components/icon), [use-motion](https://nativecn.dev/docs/primitives/use-motion).

Packages: `lucide-react-native@^1.51.0`, `react-native-svg`, `react-native-reanimated`, `react-native-worklets`.

## Usage

### Spinner demo

Spinners in each size, a Colour Role and a specific name.

```tsx
import { View } from "react-native";

import { Spinner } from "@/components/spinner";
import { createStyles } from "@/theme";

const useStyles = createStyles((t) => ({
  row: { flexDirection: "row", alignItems: "center", gap: t.spacing[6] },
}));

/** The three sizes, a Colour Role and a specific accessible name. */
export default function SpinnerDemo() {
  const styles = useStyles();
  return (
    <View style={styles.row}>
      <Spinner size="sm" />
      <Spinner />
      <Spinner size="lg" color="primary" aria-label="Loading messages" />
    </View>
  );
}
```

## Props

| Prop | Type and description |
|---|---|
| `size` | sm \| md \| lg (default md): 16 · 20 · 24 from the iconSize Tokens |
| `color` | Colour Role (default foreground) |
| `aria-label` | string (default "Loading") |
| `aria-hidden` | boolean: silences it inside a parent that is already busy |

## Variants

- `sm`, `md`, `lg`

## Accessibility

- role progressbar with aria-busy, named "Loading" by default; aria-label renames it.
- aria-hidden silences it inside a parent that is already busy; Button does this while loading.
- Still under Reduce Motion: it does not turn.

## Notes

A Lucide LoaderCircle turning once every 800ms (2 × the slow motion Token). Button uses it for `loading`. Use it instead of ActivityIndicator.

## Source

### {components}/spinner.tsx

```tsx
import { LoaderCircle } from "lucide-react-native";
import { useEffect, type Ref } from "react";
import type { View, ViewProps } from "react-native";
import Animated, {
  Easing,
  cancelAnimation,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from "react-native-reanimated";

import { Icon } from "@/components/icon";
import { useMotion } from "@/hooks/use-motion";
import { createStyles, useTheme, type ColorRole } from "@/theme";

/** Spinner sizes, from the `iconSize` Tokens (scaled): sm 16 · md 20 · lg 24. */
export type SpinnerSize = "sm" | "md" | "lg";

/** One full turn lasts this many `slow` motion Tokens (2 × 400 = 800ms). */
const TURN_IN_SLOW_STEPS = 2;

export type SpinnerProps = Omit<ViewProps, "children"> & {
  /** sm 16 · md 20 · lg 24, before Scale (default `md`). */
  size?: SpinnerSize;
  /** A Colour Role (default `foreground`). */
  color?: ColorRole;
  /** What is loading, for screen readers (default `"Loading"`). */
  "aria-label"?: string;
  ref?: Ref<View>;
};

const useStyles = createStyles((t) => ({
  sm: { width: t.iconSize.sm, height: t.iconSize.sm },
  md: { width: t.iconSize.md, height: t.iconSize.md },
  lg: { width: t.iconSize.lg, height: t.iconSize.lg },
}));

/**
 * An indeterminate loading indicator: a Lucide `LoaderCircle` turning at a steady pace. It is
 * announced as a busy progress bar named "Loading" (override with `aria-label`). Under Reduce
 * Motion it stays still. Inside a busy parent (e.g. a loading Button) pass `aria-hidden` so it
 * is not read twice. `style` is merged last.
 */
export function Spinner({
  size = "md",
  color = "foreground",
  "aria-label": label = "Loading",
  "aria-hidden": hidden,
  style,
  ...props
}: SpinnerProps) {
  const { motion } = useTheme();
  const { reduced } = useMotion();
  const styles = useStyles();
  const rotation = useSharedValue(0);

  useEffect(() => {
    if (reduced) {
      cancelAnimation(rotation);
      rotation.set(0);
      return;
    }
    rotation.set(
      withRepeat(
        withTiming(360, {
          duration: motion.duration.slow * TURN_IN_SLOW_STEPS,
          easing: Easing.linear,
        }),
        -1,
      ),
    );
    return () => cancelAnimation(rotation);
  }, [reduced, motion, rotation]);

  const spin = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.get()}deg` }] }));

  return (
    <Animated.View
      role={hidden ? undefined : "progressbar"}
      aria-label={hidden ? undefined : label}
      aria-busy={!hidden}
      aria-hidden={hidden}
      accessible={!hidden}
      importantForAccessibility={hidden ? "no-hide-descendants" : "yes"}
      style={[styles[size], spin, style]}
      {...props}
    >
      <Icon icon={LoaderCircle} size={size} color={color} />
    </Animated.View>
  );
}
```
