# Click Wheel docs

> Documentation for https://click-wheel.jackymo.me/docs. Component source: https://click-wheel.jackymo.me/llms-full.txt

## Getting Started

An unstyled rotary input for React. Supports dragging, scrolling, keyboard input, haptics and optional inertia.

Page: https://click-wheel.jackymo.me/docs

### Installation

```bash
pnpm add click-wheel
```

### Create a click wheel

**player.tsx**

```tsx
import { ClickWheel } from "click-wheel";

<ClickWheel.Root
  value={seconds}
  onValueChange={setSeconds}
  max={duration}
  unitsPerTurn={60}
  detent={5}
>
  <ClickWheel.Ring aria-label="Playback position">
    <ClickWheel.Rotor />
  </ClickWheel.Ring>
  <ClickWheel.Center aria-label="Play" onClick={togglePlay}>
    <PlayIcon />
  </ClickWheel.Center>
</ClickWheel.Root>
```

`Root` manages the value and interactions. `Ring` handles pointer, scroll and keyboard input. `Rotor` rotates with the gesture. `Center` is a button that does not start a drag. Use `value` for controlled state or `defaultValue` for uncontrolled state.

### Style it

Add styles with `className` or `style`. This example uses Tailwind classes.

**examples/basic.tsx**

```tsx
"use client";

import * as React from "react";
import { ClickWheel } from "click-wheel";

const DURATION = 227;

function fmt(s: number) {
  return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
}

export function Basic() {
  const [seconds, setSeconds] = React.useState(72);

  return (
    <div className="flex flex-col items-center gap-6">
      <ClickWheel.Root
        value={seconds}
        onValueChange={setSeconds}
        max={DURATION}
        unitsPerTurn={60}
        detent={5}
        className="relative aspect-square w-48"
      >
        <ClickWheel.Ring
          aria-label="Playback position"
          className="absolute inset-0 cursor-grab rounded-full border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring data-[dragging]:cursor-grabbing"
        >
          <ClickWheel.Rotor className="absolute inset-0 rounded-full opacity-25 [background:repeating-conic-gradient(var(--foreground)_0_1deg,transparent_1deg_15deg)] [mask:radial-gradient(circle_closest-side,transparent_56%,black_57%_82%,transparent_83%)]" />
        </ClickWheel.Ring>
        <ClickWheel.Center
          aria-label="Back to start"
          onClick={() => setSeconds(0)}
          className="absolute left-1/2 top-1/2 size-[38%] -translate-x-1/2 -translate-y-1/2 rounded-full border bg-background shadow-xs active:scale-95"
        />
      </ClickWheel.Root>
      <p className="font-mono text-sm tabular-nums">
        {fmt(seconds)} <span className="text-muted-foreground">/ {fmt(DURATION)}</span>
      </p>
    </div>
  );
}
```

## Styling

Style the parts with data attributes, CSS variables or functions of state.

Page: https://click-wheel.jackymo.me/docs/styling

### Data attributes

Every part exposes `data-dragging`, `data-coasting` and `data-disabled`. Use them with Tailwind variants or CSS attribute selectors.

**Tailwind**

```tsx
<ClickWheel.Ring className="rounded-full bg-muted cursor-grab data-[dragging]:cursor-grabbing data-[disabled]:opacity-50" />
```

### CSS variables

`Root` sets three variables. `--click-wheel-fraction` is the value as a number from 0 to 1. `--click-wheel-turns` is the value's distance from `min` in revolutions, so 1.5 means one and a half revolutions. `--click-wheel-rotation` is the cumulative angle of the rotor in degrees. During a gesture they update directly from the continuous position. When it ends, progress reconciles to the accepted value, including any step rounding. Changes to bounds and gearing also update progress.

**wheel.css**

```css
.arc {
  background: conic-gradient(
    var(--primary) calc(var(--click-wheel-fraction) * 360deg),
    transparent 0
  );
}

.groove-3 {
  background: conic-gradient(
    var(--primary) calc(clamp(0, calc(var(--click-wheel-turns) - 2), 1) * 360deg),
    transparent 0
  );
}

.needle {
  rotate: var(--click-wheel-rotation, 0deg);
}
```

`Rotor` sets `will-change: transform` to reduce repainting during rotation. Avoid CSS transitions on `rotate`; restarting a transition on each pointer update can cause stuttering.

### Functions of state

`className` and `style` accept a function of the part's state. The state is `{ value, dragging, coasting, disabled }`.

**className as a function**

```tsx
<ClickWheel.Ring
  className={(state) => (state.dragging ? "ring ring-held" : "ring")}
  style={(state) => ({ opacity: state.disabled ? 0.5 : 1 })}
/>
```

### The render prop

Replace a part's element or compose it with your own component. Handlers chain, class names join, other props override. Refs are composed, including callback cleanup functions. Custom components must forward the received props and ref.

**render**

```tsx
<ClickWheel.Center render={<div />} />

<ClickWheel.Center render={<Button variant="ghost" />} />
```

## API Reference

Page: https://click-wheel.jackymo.me/docs/api

### Anatomy

**anatomy**

```tsx
import { ClickWheel } from "click-wheel";

<ClickWheel.Root
  value={seconds}
  onValueChange={setSeconds}
  max={duration}
  unitsPerTurn={60}
  detent={5}
>
  <ClickWheel.Ring aria-label="Playback position">
    <ClickWheel.Rotor />
  </ClickWheel.Ring>
  <ClickWheel.Center aria-label="Play" onClick={togglePlay}>
    <PlayIcon />
  </ClickWheel.Center>
</ClickWheel.Root>
```

### Root

Renders a `div`. Holds the value, the gestures, and the CSS variables.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| value | number | — | Controlled value. The accepted prop determines the value and settled progress. |
| defaultValue | number | min | Starting value when uncontrolled. |
| min | number | 0 | Lower bound. |
| max | number | 100 | Upper bound. |
| step | number | 1 | Granularity of emitted values. Also the arrow-key step. Both bounds remain reachable even when off the step grid. |
| unitsPerTurn | number | 100 | The gearing: how far one full revolution moves the value. |
| detent | number | 0 | Units between detents. onTick fires for every crossing, including multiple crossings in one update. 0 disables detents. |
| haptics | boolean | true | Pulse the motor when detents are crossed, where supported. Multiple crossings in one update share a pulse. |
| inertia | boolean | false | Continue spinning after release. Touch resumes dragging; scroll and keyboard input stop coasting. |
| decelerationRate | number | 0.998 | Velocity retained per millisecond while coasting. Lower values stop sooner. |
| disabled | boolean | false | Inert. Cancels an active interaction without committing, sets data-disabled on every part, and omits the hidden input from form submission. |
| name | string | — | Renders a hidden input with this name, for forms. |
| onValueChange | (value: number, details: ChangeDetails) => void | — | Fires on every change while dragging, coasting, scrolling or keying. |
| onValueCommitted | (value: number, details: ChangeDetails) => void | — | Fires once when an interaction completes, even if the value did not change. With inertia, waits until the wheel settles. Cancelled interactions do not commit. |
| onInteractionChange | (active: boolean, details: InteractionDetails) => void | — | Brackets pointer, scroll and keyboard interactions, including coasting. Always ends, including cancellation and unmount. |
| onDraggingChange | (dragging: boolean) => void | — | Reports only when a pointer takes or releases the ring. Use onInteractionChange to track the full interaction, including coasting, scroll and keyboard input. |
| onTick | (direction: 1 \| -1) => void | — | Fires per detent crossing, with the direction of travel. |

### Interaction lifecycle

`ClickWheel.ChangeDetails` contains `source`: `pointer`, `wheel`, or `keyboard`. `ClickWheel.InteractionDetails` also contains `cancelled`. An interaction starts with `onInteractionChange(true)`, emits value changes, commits once, then ends with `onInteractionChange(false)`. A pointer interaction includes its coast; grabbing that coast continues the same interaction. Scroll events are grouped until 150 ms of inactivity; each keyboard action commits immediately.

Disabling, pointer cancellation, losing capture, window blur during a drag, or unmounting ends the interaction with `cancelled: true` and no commit. Already accepted values remain. Use `onInteractionChange` to suspend and resume playback-driven updates, and `onValueCommitted` to seek. `onDraggingChange` only reports whether a pointer holds the ring.

### Ring

Renders a `div` with `role="slider"`. Pointer, scroll and keyboard input live here.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| aria-label | string | — | The accessible name. The ring is the slider. |
| getAriaValueText | (value: number) => string | — | Formats the value for screen readers, e.g. seconds to “1 min 20 sec”. |

### Rotor

Renders an `aria-hidden` `div` with `rotate: var(--click-wheel-rotation)`. Rotates with pointer movement. Has no additional props.

### Center

Renders a `button` with `type=button`, including when composed with `render`. An explicit `type=submit` is respected. Pass `onClick` and an `aria-label`, or render a `div` for a plain hub. A disabled Root prevents Center activation even when a custom element overrides its disabled prop.

### Common props

Every part accepts these, plus the props of the element it renders.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| className | string \| (state) => string | — | A class string, or a function of the part's state. |
| style | CSSProperties \| (state) => CSSProperties | — | A style object, or a function of the part's state. |
| render | ReactElement \| (props, state) => ReactElement | — | Replace the default element or compose with your own component. Props merge: handlers chain, classes join. |

### State

The object passed to `className`, `style` and `render` functions, exported as `ClickWheel.State`.

| Field | Type | Description |
| --- | --- | --- |
| value | number | The current value. |
| dragging | boolean | A pointer is holding the ring. |
| coasting | boolean | The ring is coasting after release. |
| disabled | boolean | The wheel is disabled. |

### Helpers

| Export | Type | Description |
| --- | --- | --- |
| haptic(ms?) | (durationMs?: number) => void | One pulse. The duration applies to the Vibration API, default 4. |
| hapticsSupported() | () => boolean | Whether the browser supports a haptics method used by the component. |
| <HapticTap /> | input props | An invisible switch that covers its parent so a real tap ticks on iOS. The click still bubbles. |

### Keyboard

| Key | Action |
| --- | --- |
| Arrow Up / Arrow Right | + step |
| Arrow Down / Arrow Left | − step |
| Page Up / Page Down | ± one sixth of a turn |
| Home / End | min / max |

The ring announces itself as a slider with `aria-valuenow`, `aria-valuemin` and `aria-valuemax`. Use `getAriaValueText` for a spoken format.

## Default

Examples of styling, gearing, detents, inertia and controlled state.

Page: https://click-wheel.jackymo.me/examples/default

### Basic

The four parts with Tailwind classes. One lap is one minute; a detent every five seconds.

**examples/basic.tsx**

```tsx
"use client";

import * as React from "react";
import { ClickWheel } from "click-wheel";

const DURATION = 227;

function fmt(s: number) {
  return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
}

export function Basic() {
  const [seconds, setSeconds] = React.useState(72);

  return (
    <div className="flex flex-col items-center gap-6">
      <ClickWheel.Root
        value={seconds}
        onValueChange={setSeconds}
        max={DURATION}
        unitsPerTurn={60}
        detent={5}
        className="relative aspect-square w-48"
      >
        <ClickWheel.Ring
          aria-label="Playback position"
          className="absolute inset-0 cursor-grab rounded-full border bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring data-[dragging]:cursor-grabbing"
        >
          <ClickWheel.Rotor className="absolute inset-0 rounded-full opacity-25 [background:repeating-conic-gradient(var(--foreground)_0_1deg,transparent_1deg_15deg)] [mask:radial-gradient(circle_closest-side,transparent_56%,black_57%_82%,transparent_83%)]" />
        </ClickWheel.Ring>
        <ClickWheel.Center
          aria-label="Back to start"
          onClick={() => setSeconds(0)}
          className="absolute left-1/2 top-1/2 size-[38%] -translate-x-1/2 -translate-y-1/2 rounded-full border bg-background shadow-xs active:scale-95"
        />
      </ClickWheel.Root>
      <p className="font-mono text-sm tabular-nums">
        {fmt(seconds)} <span className="text-muted-foreground">/ {fmt(DURATION)}</span>
      </p>
    </div>
  );
}
```

### Theme installation

The following examples use the shadcn theme. Add it to a project configured for shadcn before copying the examples.

```bash
pnpm dlx shadcn@latest add https://click-wheel.jackymo.me/r/click-wheel-shadcn.json
```

### Gearing

`unitsPerTurn` sets the value change per revolution. A smaller value gives finer control without changing `min` or `max`.

**examples/gearing.tsx**

```tsx
"use client";

import * as React from "react";
import { Wheel } from "@/components/click-wheel-shadcn/wheel";

const DURATION = 227;
const GEARINGS = [30, 60, 300];

function fmt(s: number) {
  return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
}

export function Gearing() {
  const [seconds, setSeconds] = React.useState(72);
  const [unitsPerTurn, setUnitsPerTurn] = React.useState(60);

  return (
    <div className="flex flex-col items-center gap-6">
      <Wheel
        value={seconds}
        onValueChange={setSeconds}
        max={DURATION}
        unitsPerTurn={unitsPerTurn}
        detent={5}
        label="Playback position"
        className="w-48"
      />
      <div className="inline-flex rounded-md border bg-muted p-0.5 text-xs font-medium">
        {GEARINGS.map((units) => (
          <button
            key={units}
            type="button"
            onClick={() => setUnitsPerTurn(units)}
            data-active={units === unitsPerTurn || undefined}
            className="rounded-sm px-3 py-1.5 text-muted-foreground data-[active]:bg-background data-[active]:text-foreground data-[active]:shadow-xs"
          >
            {units >= 60 ? `${units / 60} min` : `${units} s`} / turn
          </button>
        ))}
      </div>
      <p className="font-mono text-sm tabular-nums">{fmt(seconds)}</p>
    </div>
  );
}
```

### Detents

`detent` sets the units between clicks. Each crossing fires `onTick`, which here plays a short blip. Haptics pulse once per update that crosses a detent.

**examples/detents.tsx**

```tsx
"use client";

import * as React from "react";
import { Wheel } from "@/components/click-wheel-shadcn/wheel";

let ctx: AudioContext | null = null;

function click() {
  ctx ??= new AudioContext();
  if (ctx.state === "suspended") void ctx.resume();
  const osc = ctx.createOscillator();
  const gain = ctx.createGain();
  osc.type = "square";
  osc.frequency.value = 1800;
  gain.gain.setValueAtTime(0.03, ctx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.012);
  osc.connect(gain).connect(ctx.destination);
  osc.start();
  osc.stop(ctx.currentTime + 0.014);
}

export function Detents() {
  const [value, setValue] = React.useState(40);
  const [ticks, setTicks] = React.useState(0);
  const [sound, setSound] = React.useState(true);

  return (
    <div className="flex flex-col items-center gap-6">
      <Wheel
        value={value}
        onValueChange={setValue}
        unitsPerTurn={120}
        detent={5}
        onTick={() => {
          setTicks((n) => n + 1);
          if (sound) click();
        }}
        label="Volume"
        className="w-48"
      />
      <div className="flex items-center gap-4 text-sm">
        <label className="flex items-center gap-2">
          <input type="checkbox" checked={sound} onChange={(e) => setSound(e.target.checked)} />
          Clicker
        </label>
        <span className="font-mono tabular-nums text-muted-foreground">
          {value} · {ticks} ticks
        </span>
      </div>
    </div>
  );
}
```

### Inertia

Enable `inertia` to keep the wheel spinning after release. Touching the ring resumes dragging; scroll and keyboard input stop coasting. `data-coasting` marks this state, and `onValueCommitted` fires when it settles. `decelerationRate` controls the slowdown: lower values stop sooner.

**examples/inertia.tsx**

```tsx
"use client";

import * as React from "react";
import { Wheel } from "@/components/click-wheel-shadcn/wheel";

const RATES = [
  { label: "normal", rate: 0.998 },
  { label: "fast stop", rate: 0.99 },
];

export function Inertia() {
  const [value, setValue] = React.useState(40);
  const [inertia, setInertia] = React.useState(true);
  const [rate, setRate] = React.useState(0.998);

  return (
    <div className="flex flex-col items-center gap-6">
      <Wheel
        value={value}
        onValueChange={setValue}
        max={1000}
        unitsPerTurn={100}
        detent={10}
        inertia={inertia}
        decelerationRate={rate}
        label="Level"
        className="w-48"
      />
      <div className="flex flex-wrap items-center justify-center gap-4 text-sm">
        <label className="flex items-center gap-2">
          <input type="checkbox" checked={inertia} onChange={(e) => setInertia(e.target.checked)} />
          Inertia
        </label>
        <div className="inline-flex rounded-md border bg-muted p-0.5 text-xs font-medium">
          {RATES.map((r) => (
            <button
              key={r.rate}
              type="button"
              onClick={() => setRate(r.rate)}
              data-active={r.rate === rate || undefined}
              className="rounded-sm px-3 py-1.5 text-muted-foreground data-[active]:bg-background data-[active]:text-foreground data-[active]:shadow-xs"
            >
              {r.label}
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}
```

### Controlled

Own the value with `value` and `onValueChange`. `onInteractionChange` tells you when the wheel owns the position, including scroll, keyboard and coasting. Freeze playback-driven readout updates while active, then seek the audio in `onValueCommitted`. Audio can keep playing throughout.

**examples/controlled.tsx**

```tsx
"use client";

import * as React from "react";
import { Wheel } from "@/components/click-wheel-shadcn/wheel";

const DURATION = 227;

function fmt(s: number) {
  return `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
}

export function Controlled() {
  const [seconds, setSeconds] = React.useState(72);
  const [playing, setPlaying] = React.useState(false);
  const [held, setHeld] = React.useState(false);
  const [log, setLog] = React.useState<string[]>([]);

  React.useEffect(() => {
    if (!playing || held) return;
    const id = setInterval(() => setSeconds((s) => (s + 1) % DURATION), 1000);
    return () => clearInterval(id);
  }, [playing, held]);

  return (
    <div className="flex flex-col items-center gap-6">
      <Wheel
        value={seconds}
        onValueChange={setSeconds}
        onInteractionChange={setHeld}
        onValueCommitted={(v) => {
          setLog((l) => [`committed ${fmt(v)}`, ...l].slice(0, 3));
        }}
        max={DURATION}
        unitsPerTurn={60}
        detent={5}
        label="Playback position"
        icon={playing ? "❚❚" : "▶"}
        centerLabel={playing ? "Pause" : "Play"}
        onCenterClick={() => setPlaying((p) => !p)}
        className="w-48"
      />
      <p className="font-mono text-sm tabular-nums">
        {fmt(seconds)} · {playing ? (held ? "scrubbing" : "playing") : "paused"}
      </p>
      <ul className="min-h-[3.75rem] font-mono text-xs text-muted-foreground">
        {log.map((entry, i) => (
          <li key={i}>{entry}</li>
        ))}
      </ul>
    </div>
  );
}
```

## Themes

Page: https://click-wheel.jackymo.me/examples/themes

### Skins

Add a theme with the shadcn CLI, or copy its source. Each theme uses the click-wheel package. Duotone uses the shadcn theme with the color pairs shown on the site.

- `pnpm dlx shadcn@latest add https://click-wheel.jackymo.me/r/click-wheel-shadcn.json`
- `pnpm dlx shadcn@latest add https://click-wheel.jackymo.me/r/click-wheel-ipod.json`
- `pnpm dlx shadcn@latest add https://click-wheel.jackymo.me/r/click-wheel-retro.json`
- `pnpm dlx shadcn@latest add https://click-wheel.jackymo.me/r/click-wheel-galley.json`
