# Spinner

> Eight-segment radial spinner for indeterminate loading that inherits the current text color.

- **Slug:** `spinner` · **Category:** primitives · **Status:** stable
- **Import:** `import { Spinner } from "@kura-ui/react/spinner";`
- **Docs:** https://kuro.abstergo.fyi/components/spinner
- **Registry item:** https://kuro.abstergo.fyi/r/spinner.json
- **Tags:** loading, spinner, busy, progress, indeterminate

## Install

```bash
# shadcn registry (copies source into your project)
npx shadcn@latest add https://kuro.abstergo.fyi/r/spinner.json
# Kura CLI (same, plus dependency resolution and --json output)
npx @kura-ui/cli add spinner
# npm package
npm install @kura-ui/react
```

## When to use

- Indeterminate waits on a control: a loading button, a pending row, a fetching list.
- Small inline busy indicators next to text.

## When not to use

- AI ‘thinking’ states — use shimmer-text or thinking-indicator; they read as the model working, not the network.
- Known progress — use a progress bar or context-meter.
- Inside a Button — pass `loading` to button instead.

## Props

| Prop | Type | Default | Required | Description |
| --- | --- | --- | --- | --- |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | no | 12px, 16px or 20px. |
| `label` | `string \| null` | `"Loading"` | no | Accessible label (role=status). Pass null when a parent already announces loading. |

Native attributes of the root element, `className` and `ref` are passed through.

## Accessibility

- Role: `status`
- With a label it renders role="status" containing visually hidden label text; with label={null} it is aria-hidden.
- Reduced motion: Rotation is replaced by a slow opacity pulse.

## Notes for agents

- Color it with a text-* class on the spinner or a parent; it uses currentColor.

## Examples

### Default (minimal)

```tsx
import { Spinner } from "@kura-ui/react/spinner";

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

### Sizes and colors (full)

```tsx
import { Spinner } from "@kura-ui/react/spinner";

export function SpinnerFull() {
  return (
    <div className="flex items-center gap-6 text-kura-fg-muted">
      <Spinner size="sm" label="Loading small" />
      <Spinner size="md" label="Loading medium" />
      <Spinner size="lg" label="Loading large" className="text-kura-accent" />
      <span className="inline-flex items-center gap-2 text-sm">
        <Spinner size="sm" label={null} /> Fetching models…
      </span>
    </div>
  );
}
```

### Syncing knowledge sources (composition)

Per-row spinners for indexing, a loading Sync now Button and a Reconnect action for a failed source.

```tsx
"use client";

import { Check, FileText, FolderGit2, HardDrive, ListTodo, TriangleAlert } from "lucide-react";
import * as React from "react";
import { Button } from "@kura-ui/react/button";
import { Spinner } from "@kura-ui/react/spinner";

const initialSources: Array<{
  id: string;
  name: string;
  detail: string;
  icon: React.ReactNode;
  status: SourceStatus;
}> = [
  {
    id: "drive",
    name: "Product specs",
    detail: "Drive · 1,204 files",
    icon: <HardDrive />,
    status: "synced",
  },
  {
    id: "wiki",
    name: "Team wiki",
    detail: "Notion · 386 pages",
    icon: <FileText />,
    status: "syncing",
  },
  {
    id: "repo",
    name: "kura-ui/kura",
    detail: "GitHub · main",
    icon: <FolderGit2 />,
    status: "synced",
  },
  {
    id: "issues",
    name: "Roadmap issues",
    detail: "Linear · access expired",
    icon: <ListTodo />,
    status: "error",
  },
];

export function SpinnerSourcesList() {
  const [sources, setSources] = React.useState(initialSources);
  const [syncingAll, setSyncingAll] = React.useState(false);
  const syncing = sources.some((s) => s.status === "syncing");
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);
  React.useEffect(() => () => timers.current.forEach(clearTimeout), []);

  // Let the initial "Team wiki" sync finish so the demo settles.
  React.useEffect(() => {
    const id = setTimeout(
      () => setSources((all) => all.map((s) => (s.id === "wiki" ? { ...s, status: "synced" } : s))),
      3200,
    );
    return () => clearTimeout(id);
  }, []);

  const settle = (id: string, ms: number) =>
    timers.current.push(
      setTimeout(
        () => setSources((all) => all.map((s) => (s.id === id ? { ...s, status: "synced" } : s))),
        ms,
      ),
    );
  const syncOne = (id: string) => {
    setSources((all) => all.map((s) => (s.id === id ? { ...s, status: "syncing" } : s)));
    settle(id, 1800);
  };
  // Re-index healthy sources; one that needs reconnecting stays put until the user acts.
  const syncAll = () => {
    const healthy = sources.filter((s) => s.status !== "error");
    setSources((all) => all.map((s) => (s.status === "error" ? s : { ...s, status: "syncing" })));
    for (const [i, s] of healthy.entries()) settle(s.id, 900 + i * 650);
    setSyncingAll(true);
    timers.current.push(setTimeout(() => setSyncingAll(false), 900 + healthy.length * 650));
  };

  return (
    <section
      aria-labelledby="sources-title"
      className="flex w-full max-w-md flex-col rounded-kura-xl bg-kura-surface shadow-kura-sm"
    >
      <div className="flex items-center justify-between gap-3 px-4 pt-4 pb-2">
        <div className="flex flex-col gap-0.5">
          <h3 id="sources-title" className="font-medium text-kura-fg text-sm">
            Knowledge sources
          </h3>
          <p className="text-kura-fg-muted text-xs" aria-live="polite">
            {syncing ? "Syncing — answers may miss recent changes" : "Indexed 2 minutes ago"}
          </p>
        </div>
        <Button variant="secondary" size="sm" loading={syncingAll} onClick={syncAll}>
          Sync now
        </Button>
      </div>
      <ul className="flex flex-col p-1.5">
        {sources.map((source) => (
          <li
            key={source.id}
            data-status={source.status}
            className="flex items-center gap-3 rounded-kura-md px-2.5 py-2"
          >
            <span className="flex size-8 shrink-0 items-center justify-center rounded-kura-md bg-kura-surface-sunken text-kura-fg-muted shadow-kura-xs [&_svg]:size-4">
              {source.icon}
            </span>
            <span className="flex min-w-0 flex-1 flex-col">
              <span className="truncate font-medium text-[13px] text-kura-fg">{source.name}</span>
              <span className="truncate text-kura-fg-subtle text-xs">{source.detail}</span>
            </span>
            {source.status === "error" ? (
              <Button
                variant="outline"
                size="xs"
                aria-label={`Reconnect ${source.name}`}
                onClick={() => syncOne(source.id)}
              >
                <TriangleAlert className="text-kura-danger-text" />
                Reconnect
              </Button>
            ) : (
              <span className="inline-flex shrink-0 items-center gap-1.5 text-kura-fg-muted text-xs">
                {source.status === "syncing" ? (
                  <Spinner size="sm" label={null} />
                ) : (
                  <Check aria-hidden className="size-3.5 text-kura-success-text" />
                )}
                {source.status === "syncing" ? "Indexing…" : "Up to date"}
              </span>
            )}
          </li>
        ))}
      </ul>
    </section>
  );
}
```

## Dependencies

- npm: none
- Kura registry items: `kura-theme`, `utils`
- Related: `button`, `shimmer-text`, `thinking-indicator`
