# Spinner

> Ring, dots and bars loaders in any neon color.

Category: Feedback. Docs: https://get-neonkit.vercel.app/components/spinner

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/spinner.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/spinner`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

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

<Spinner variant="orbit" label="Loading invoices" />
```

## Examples

### Variants

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

const variants = ["ring", "dots", "bars", "orbit"] as const;

export default function SpinnerDemo() {
  return (
    <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
      {variants.map((variant) => (
        <div
          key={variant}
          className="flex w-28 flex-col items-center gap-4 rounded-xl border border-white/10 bg-white/[0.03] pt-7 pb-4"
        >
          <Spinner variant={variant} size="lg" />
          <span className="font-mono text-xs text-white/45">{variant}</span>
        </div>
      ))}
    </div>
  );
}
```

### Sizes

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

const variants = ["ring", "dots", "bars", "orbit"] as const;
const sizes = ["sm", "md", "lg"] as const;

export default function SpinnerSizes() {
  return (
    <div className="grid grid-cols-[auto_repeat(3,3rem)] items-center gap-x-4 gap-y-5">
      <span />
      {sizes.map((size) => (
        <span key={size} className="text-center font-mono text-xs text-white/45">
          {size}
        </span>
      ))}
      {variants.map((variant) => (
        <div key={variant} className="contents">
          <span className="pr-2 font-mono text-xs text-white/45">{variant}</span>
          {sizes.map((size) => (
            <div key={size} className="flex justify-center">
              <Spinner variant={variant} size={size} />
            </div>
          ))}
        </div>
      ))}
    </div>
  );
}
```

### In a button

The spinner picks up the button's color. Hide it from assistive tech and set aria-busy on the button.

```tsx
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";

function usePending(ms: number) {
  const [pending, setPending] = React.useState(false);
  const start = () => {
    if (pending) return;
    setPending(true);
    setTimeout(() => setPending(false), ms);
  };
  return [pending, start] as const;
}

export default function SpinnerButton() {
  const [checking, runChecks] = usePending(2400);
  const [publishing, publish] = usePending(2000);

  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      <Button aria-busy={checking || undefined} onClick={runChecks}>
        {checking && <Spinner size="sm" aria-hidden />}
        {checking ? "Running checks" : "Run checks"}
      </Button>
      <Button
        variant="soft"
        color="violet"
        aria-busy={publishing || undefined}
        onClick={publish}
      >
        {publishing && <Spinner size="sm" variant="dots" aria-hidden />}
        {publishing ? "Publishing" : "Publish post"}
      </Button>
    </div>
  );
}
```

### Inline status

Next to visible text, the wrapper carries role="status" and the spinner is aria-hidden.

```tsx
"use client";

import * as React from "react";
import { Check } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";

export default function SpinnerInline() {
  const [syncing, setSyncing] = React.useState(true);

  React.useEffect(() => {
    const id = setInterval(() => setSyncing((s) => !s), 2600);
    return () => clearInterval(id);
  }, []);

  return (
    <div className="flex w-full max-w-md items-center justify-between gap-4 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
      <div className="min-w-0">
        <p className="truncate text-sm font-medium text-white/90">Q4 launch plan</p>
        <p className="text-xs text-white/45">Edited by Maya Chen</p>
      </div>
      <p role="status" className="flex shrink-0 items-center gap-2 text-xs text-white/55">
        {syncing ? (
          <>
            <Spinner size="sm" variant="orbit" aria-hidden />
            Syncing…
          </>
        ) : (
          <>
            <Check className="size-4 text-white/40" aria-hidden />
            All changes saved
          </>
        )}
      </p>
    </div>
  );
}
```

## API reference

### Spinner

Also accepts every native span attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"ring" \| "dots" \| "bars" \| "orbit"` | `"ring"` | Shape of the loader. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | 16, 24 or 36px square. |
| `label` | `string` | `"Loading"` | Visually hidden text announced to screen readers. |

## Accessibility

- Spinner renders role="status" with a visually hidden label, so screen readers announce it once when it appears.
- When the spinner sits beside text that already says what is happening, pass aria-hidden so the message isn't read twice.
- With reduced motion, every variant runs three times slower instead of stopping, so it still reads as busy.

## Source

`components/ui/spinner.tsx`

```tsx
import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const keyframes = `
@keyframes nk-spinner-dot { 0%, 80%, 100% { opacity: 0.25; transform: scale(0.75); } 40% { opacity: 1; transform: scale(1); } }
@keyframes nk-spinner-bar { 0%, 100% { opacity: 0.45; transform: scaleY(0.35); } 50% { opacity: 1; transform: scaleY(1); } }
`;

const sizes = {
  sm: { box: "size-4", stroke: 3, dot: 3, gap: 2, bar: 2, orbit: 4 },
  md: { box: "size-6", stroke: 2.5, dot: 5, gap: 3, bar: 3, orbit: 5 },
  lg: { box: "size-9", stroke: 2, dot: 7, gap: 4, bar: 4, orbit: 7 },
} as const;

type SpinnerSize = keyof typeof sizes;

// Every duration is multiplied by --nk-spinner-slow, which reduced motion
// raises, so the spinner slows down and still reads as busy.
const seconds = (s: number) => `calc(${s}s * var(--nk-spinner-slow))`;

const RING_RADIUS = 9;
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS;

function Ring({ size }: { size: SpinnerSize }) {
  return (
    <svg
      aria-hidden
      viewBox="0 0 24 24"
      fill="none"
      strokeWidth={sizes[size].stroke}
      className="size-full animate-spin [animation-duration:calc(0.8s*var(--nk-spinner-slow))] [filter:drop-shadow(0_0_3px_rgb(var(--neon)/0.8))]"
    >
      <circle cx={12} cy={12} r={RING_RADIUS} className="[stroke:rgb(var(--neon)/0.15)]" />
      <circle
        cx={12}
        cy={12}
        r={RING_RADIUS}
        strokeLinecap="round"
        strokeDasharray={`${RING_CIRCUMFERENCE * 0.28} ${RING_CIRCUMFERENCE}`}
        className="[stroke:rgb(var(--neon))]"
      />
    </svg>
  );
}

function Dots({ size }: { size: SpinnerSize }) {
  const { dot, gap } = sizes[size];
  return (
    <span aria-hidden className="flex items-center" style={{ gap }}>
      {[0, 0.16, 0.32].map((delay) => (
        <span
          key={delay}
          className="rounded-full bg-[rgb(var(--neon))] shadow-[0_0_6px_rgb(var(--neon)/0.7)]"
          style={{
            width: dot,
            height: dot,
            animation: `nk-spinner-dot ${seconds(1.2)} ease-in-out ${seconds(delay)} infinite both`,
          }}
        />
      ))}
    </span>
  );
}

function Bars({ size }: { size: SpinnerSize }) {
  const { bar, gap } = sizes[size];
  return (
    <span aria-hidden className="flex h-3/4 items-center" style={{ gap }}>
      {/* Negative delays start each bar mid-cycle, so the wave is already moving on mount. */}
      {[-1, -0.85, -0.7, -0.55].map((delay) => (
        <span
          key={delay}
          className="h-full rounded-full bg-[rgb(var(--neon))] shadow-[0_0_6px_rgb(var(--neon)/0.6)]"
          style={{
            width: bar,
            animation: `nk-spinner-bar ${seconds(1)} ease-in-out ${seconds(delay)} infinite`,
          }}
        />
      ))}
    </span>
  );
}

function Orbit({ size }: { size: SpinnerSize }) {
  const { orbit } = sizes[size];
  return (
    <>
      <span
        aria-hidden
        className="absolute rounded-full border border-[rgb(var(--neon)/0.18)]"
        style={{ inset: (orbit - 1) / 2 }}
      />
      <span
        aria-hidden
        className="absolute inset-0 animate-spin [animation-duration:calc(1.1s*var(--nk-spinner-slow))]"
      >
        <span
          className="absolute top-0 left-1/2 rounded-full bg-[rgb(var(--neon))] shadow-[0_0_8px_rgb(var(--neon)/0.9)]"
          style={{ width: orbit, height: orbit, marginLeft: -orbit / 2 }}
        />
      </span>
    </>
  );
}

const variants = { ring: Ring, dots: Dots, bars: Bars, orbit: Orbit } as const;

export interface SpinnerProps
  extends Omit<React.HTMLAttributes<HTMLSpanElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  variant?: keyof typeof variants;
  size?: SpinnerSize;
  /** Announced to screen readers, visually hidden. */
  label?: string;
}

const Spinner = React.forwardRef<HTMLSpanElement, SpinnerProps>(
  (
    { className, style, color, variant = "ring", size = "md", label = "Loading", ...props },
    ref,
  ) => {
    const Visual = variants[variant];

    return (
      <span
        ref={ref}
        role="status"
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "[--nk-spinner-slow:1] motion-reduce:[--nk-spinner-slow:3]",
          "relative inline-flex shrink-0 items-center justify-center",
          sizes[size].box,
          className,
        )}
        {...props}
      >
        <style>{keyframes}</style>
        <Visual size={size} />
        {label && <span className="sr-only">{label}</span>}
      </span>
    );
  },
);
Spinner.displayName = "Spinner";

export { Spinner };
```
