# Spindle — Animated UI Component for React

> R3F film-bobbin gallery: frames on a continuous sprocketed film strip wound helically around a reel, unwinding frame by frame into a flat contact strip as you scroll. Drag to spin or pan, arrow keys to browse, click or Enter to lift a frame into a lightbox. Off-focus duotone, idle spin, offscreen-paused, reduced-motion-safe.

- Page: https://crucibleui.com/components/utility/spindle
- Category: [utility](https://crucibleui.com/components/utility)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: r3f
- npm dependencies: three, @react-three/fiber

## Install

```bash
npx shadcn@latest add @crucible/spindle
```

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

Import from `@/components/crucible/spindle`.

## Props (`SpindleProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items?` | `SpindleItem[]` | `nine seeded procedural plates` | Frames wound onto the bobbin. |
| `progress?` | `number` | — | Controlled unwind, 0 (wound on the bobbin) → 1 (flat contact strip). When set, scroll and wheel input no longer drive the unwind. |
| `scrollDriven?` | `boolean` | `true` | Drive the unwind from scroll: the component becomes a tall pin track (`pinLength` × the scroller's viewport) with a sticky stage inside. When false (and uncontrolled), wheel / vertical drag over the stage unwinds instead. |
| `scroller?` | `React.RefObject<HTMLElement \| null> \| HTMLElement \| null` | `the nearest scrollable ancestor, else the window` | The scroll container that drives the unwind. |
| `pinLength?` | `number` | `2.6` | Pin track length as a multiple of the scroller's viewport height. |
| `duotone?` | `[string, string] \| false` | `cool silver` | Off-focus duotone as `[ink, paper]` hex colors; the focused frame shows full color. `false` keeps every frame in full color. |
| `interactive?` | `boolean` | `true` | Drag to spin / pan, click to open, wheel to unwind. Keyboard always works. |
| `paused?` | `boolean` | `false` | Freeze the idle spin and the intro; transitions still ease on demand. |
| `label?` | `string` | `"Film gallery"` | Accessible name for the gallery. |
| `children?` | `React.ReactNode` | — | Overlay content laid over the stage (pointer events pass through to the film). |
| `className?` | `string` | — | Extra classes for the root (the pin track when scroll-driven). |
| `ref?` | `React.Ref<SpindleHandle>` | — | Imperative handle: `unwind()`, `rewind()`, `open(i)`, `close()`. |

## Usage example

```tsx
"use client";

import * as React from "react";

import { Spindle } from "@/components/crucible/spindle";

export default function SpindleDemo() {
  const scrollerRef = React.useRef<HTMLDivElement>(null);

  return (
    <div
      ref={scrollerRef}
      className="relative h-[520px] w-full overflow-y-auto overflow-x-hidden rounded-xl bg-neutral-950"
    >
      <div
        aria-hidden
        className="pointer-events-none sticky top-0 -mb-[520px] h-[520px] bg-[radial-gradient(ellipse_at_50%_45%,rgba(148,163,184,0.10),transparent_62%)]"
      />
      <Spindle scroller={scrollerRef}>
        <p className="relative z-10 pt-5 text-center text-sm text-neutral-500">
          Scroll to unwind — click a frame.
        </p>
      </Spindle>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through. Pauses offscreen and on hidden tabs, clamps device pixel ratio, and tears down its GL/canvas context on unmount.
