# Click Wheel

> An unstyled rotary input for React with pointer, scroll and keyboard controls, haptics and optional inertia.

## Install

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

Requires React 19.2 or newer. Import `{ ClickWheel }` from `"click-wheel"`.

## Optional themes

Add a theme to a project configured for shadcn. Each theme depends on the npm package.

- `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`

## Docs

- [All docs as one Markdown file](https://click-wheel.jackymo.me/docs.md)
- [Getting Started](https://click-wheel.jackymo.me/docs): An unstyled rotary input for React.
- [Styling](https://click-wheel.jackymo.me/docs/styling): Style the parts with data attributes, CSS variables or functions of state..
- [API Reference](https://click-wheel.jackymo.me/docs/api)
- [Default](https://click-wheel.jackymo.me/examples/default): Examples of styling, gearing, detents, inertia and controlled state..
- [Themes](https://click-wheel.jackymo.me/examples/themes)
- [Everything in one file](https://click-wheel.jackymo.me/llms-full.txt): the docs plus the full component source
- [Registry index](https://click-wheel.jackymo.me/r/registry.json)

## Quick reference

```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>
```

- Parts have no default visual styles. State: `data-dragging` (a pointer holds the ring), `data-coasting` (coasting after release), `data-disabled`, `--click-wheel-fraction` (0–1), `--click-wheel-turns` (laps from min), `--click-wheel-rotation` (deg). `className` and `style` may be functions of `{ value, dragging, coasting, disabled }`. Every part takes a `render` prop.
- Gearing: `unitsPerTurn` sets how far one revolution moves the value. `detent` sets the units between haptic ticks. `inertia` (off by default) keeps the wheel spinning after a flick; `decelerationRate` (0.998) is the velocity kept per millisecond.
- Callbacks: `onValueChange` on every change, `onValueCommitted` once per completed interaction, `onInteractionChange` across pointer, scroll, keyboard and coasting (including cancellation), `onDraggingChange` when a pointer starts or stops dragging, `onTick` per detent.
- Haptics: Vibration API on Android; the switch trick on iOS 17.4–26.4; on iOS 26.5+ only real taps vibrate, so put `<HapticTap />` inside the center button.

---

# 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`

## Source

### components/click-wheel/index.ts

```ts
export * as ClickWheel from "./parts";
export { haptic, hapticsSupported, HapticTap } from "./haptics";
```

### components/click-wheel/parts.ts

```ts
export { Root, type RootProps, type InteractionSource, type ChangeDetails, type InteractionDetails } from "./root";
export { Ring, type RingProps } from "./ring";
export { Rotor, type RotorProps } from "./rotor";
export { Center, type CenterProps } from "./center";
export type { ClickWheelState as State } from "./context";
```

### components/click-wheel/context.ts

```ts
"use client";

import * as React from "react";

export interface ClickWheelState {
  /** The current value. */
  value: number;
  /** A pointer is holding the ring. */
  dragging: boolean;
  /** The ring is spinning on after a flick, with no hand on it. */
  coasting: boolean;
  /** Whether the wheel is disabled. */
  disabled: boolean;
}

export interface ClickWheelContextValue {
  state: ClickWheelState;
  min: number;
  max: number;
  step: number;
  ringRef: React.RefObject<HTMLElement | null>;
  centerRef: React.RefObject<HTMLElement | null>;
  onPointerDown: (event: React.PointerEvent) => void;
  onPointerMove: (event: React.PointerEvent) => void;
  onPointerUp: (event: React.PointerEvent) => void;
  onPointerCancel: (event: React.PointerEvent) => void;
  onKeyDown: (event: React.KeyboardEvent) => void;
}

export const ClickWheelContext = React.createContext<ClickWheelContextValue | null>(null);

export function useClickWheelContext(part: string): ClickWheelContextValue {
  const ctx = React.useContext(ClickWheelContext);
  if (!ctx) {
    throw new Error(`ClickWheel.${part} must be rendered inside ClickWheel.Root.`);
  }
  return ctx;
}
```

### components/click-wheel/render.tsx

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

/** Props every part accepts, following the Base UI `render` / `className` / `style` contract. */
export type PartProps<State, E extends React.ElementType> = Omit<
  React.ComponentPropsWithRef<E>,
  "className" | "style"
> & {
  /** A class string, or a function of the part's state that returns one. */
  className?: string | ((state: State) => string);
  /** A style object, or a function of the part's state that returns one. */
  style?: React.CSSProperties | ((state: State) => React.CSSProperties);
  /**
   * Replace the default element: pass an element to merge into, or a function
   * that receives the part's props and state and returns an element.
   */
  render?:
    | React.ReactElement<Record<string, unknown>>
    | ((props: Record<string, unknown>, state: State) => React.ReactElement);
};

type AnyProps = Record<string, unknown>;

function mergeRefs<T>(...refs: Array<React.Ref<T> | undefined>): React.RefCallback<T> {
  return (node) => {
    const cleanups = refs.map((ref) => {
      if (typeof ref === "function") {
        const cleanup = ref(node);
        return typeof cleanup === "function" ? cleanup : () => ref(null);
      }
      if (ref) {
        ref.current = node;
        return () => { ref.current = null; };
      }
    });
    return () => { for (const cleanup of cleanups) cleanup?.(); };
  };
}

/** Stable merged ref, so React does not detach and re-attach refs on every render. */
export function useMergedRefs<T>(
  a: React.Ref<T> | undefined,
  b: React.Ref<T> | undefined,
): React.RefCallback<T> {
  return React.useMemo(() => mergeRefs(a, b), [a, b]);
}

/**
 * Merge internal props with the user's. Handlers chain (user first), classes
 * join, styles merge, and any other external value wins.
 */
export function mergeProps(internal: AnyProps, external: AnyProps): AnyProps {
  const out: AnyProps = { ...internal };
  for (const key of Object.keys(external)) {
    const a = internal[key];
    const b = external[key];
    if (b === undefined) continue;
    if (key === "className") {
      out.className = a ? `${a} ${b}` : b;
    } else if (key === "style") {
      out.style = { ...(a as object), ...(b as object) };
    } else if (/^on[A-Z]/.test(key) && typeof a === "function" && typeof b === "function") {
      out[key] = (...args: unknown[]) => {
        b(...args);
        a(...args);
      };
    } else {
      out[key] = b;
    }
  }
  return out;
}

/**
 * Render one part. `internal` holds the props the part needs to function;
 * `external` is what the consumer passed, including `render`.
 */
export function useRenderPart<State>(
  tag: keyof React.JSX.IntrinsicElements,
  state: State,
  external: PartProps<State, React.ElementType>,
  internal: AnyProps,
): React.ReactElement {
  const { render, className, style, ...rest } = external;
  const resolved: AnyProps = {
    ...rest,
    className: typeof className === "function" ? className(state) : className,
    style: typeof style === "function" ? style(state) : style,
  };
  const props = mergeProps(internal, resolved);
  const ref = useMergedRefs(
    props.ref as React.Ref<unknown>,
    typeof render === "object" ? render.props.ref as React.Ref<unknown> : undefined,
  );
  if (typeof render === "function") return render({ ...props, ref }, state);
  if (render) return React.cloneElement(render, { ...mergeProps(props, render.props), ref });
  return React.createElement(tag, { ...props, ref });
}
```

### components/click-wheel/haptics.tsx

```tsx
"use client";

import * as React from "react";

/*
  Haptics on the web, as of late 2026:

  - Android (Chrome, Firefox, Samsung Internet): `navigator.vibrate`.
  - iOS 17.4 – 26.4: Safari has no Vibration API, but toggling a
    `<input type="checkbox" switch>` through its <label> fires the Taptic
    engine, even from script.
  - iOS 26.5+: script-driven toggles no longer vibrate. Only a real tap on a
    switch does, so render <HapticTap /> inside a tappable element.
  - Desktop: nothing vibrates. `navigator.vibrate` exists in Chrome and no-ops.
*/

function isIOS(): boolean {
  return typeof CSS !== "undefined" && CSS.supports("-webkit-touch-callout", "none");
}

/** True when this browser has any path to a haptic pulse. */
export function hapticsSupported(): boolean {
  if (typeof navigator === "undefined") return false;
  return typeof navigator.vibrate === "function" || isIOS();
}

let iosSwitch: HTMLLabelElement | null = null;

function getIOSSwitch(): HTMLLabelElement {
  if (iosSwitch) return iosSwitch;
  const label = document.createElement("label");
  label.setAttribute("aria-hidden", "true");
  label.style.cssText =
    "position:fixed;top:0;left:0;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;";
  const input = document.createElement("input");
  input.type = "checkbox";
  input.setAttribute("switch", "");
  input.tabIndex = -1;
  label.appendChild(input);
  document.body.appendChild(label);
  iosSwitch = label;
  return label;
}

/**
 * One short haptic pulse. `durationMs` only applies to the Vibration API;
 * iOS always produces its own fixed tick.
 */
export function haptic(durationMs = 4): void {
  if (typeof navigator === "undefined") return;
  if (typeof navigator.vibrate === "function") {
    navigator.vibrate(durationMs);
    return;
  }
  if (isIOS()) getIOSSwitch().click();
}

const TAP_STYLE: React.CSSProperties = {
  position: "absolute",
  inset: 0,
  width: "100%",
  height: "100%",
  margin: 0,
  opacity: 0,
  clipPath: "inset(0 round 999px)",
  WebkitTapHighlightColor: "transparent",
};

/**
 * An invisible switch that covers its parent so a real tap ticks on iOS,
 * including 26.5+. Place it inside a positioned, round, tappable element;
 * the click still bubbles to the parent. Does nothing elsewhere.
 */
export function HapticTap(props: React.ComponentProps<"input">) {
  const { style, ...rest } = props;
  return (
    <input
      type="checkbox"
      aria-hidden="true"
      tabIndex={-1}
      {...{ switch: "" }}
      {...rest}
      style={{ ...TAP_STYLE, ...style }}
    />
  );
}
```

### components/click-wheel/root.tsx

```tsx
"use client";

import * as React from "react";
import { ClickWheelContext, type ClickWheelContextValue, type ClickWheelState } from "./context";
import {
  angleAt,
  arcDelta,
  clamp,
  coastStep,
  DEFAULT_DECELERATION_RATE,
  degreesToUnits,
  detentCrossings,
  HUB_DEAD_ZONE,
  keyDelta,
  MIN_VELOCITY,
  releaseVelocity,
  type Sample,
  stepValue,
  unitsToDegrees,
  VELOCITY_WINDOW_MS,
  WHEEL_DEG_PER_PX,
} from "./core";
import { haptic } from "./haptics";
import { useRenderPart, useMergedRefs, type PartProps } from "./render";

export type InteractionSource = "pointer" | "wheel" | "keyboard";

export interface ChangeDetails {
  source: InteractionSource;
}

export interface InteractionDetails extends ChangeDetails {
  /** True when disabled, cancelled by the browser, or unmounted before committing. */
  cancelled: boolean;
}

export interface RootProps extends Omit<PartProps<ClickWheelState, "div">, "defaultValue"> {
  /** Controlled value. */
  value?: number;
  /** Starting value when uncontrolled. */
  defaultValue?: number;
  /** Fires on every value change while dragging, coasting, scrolling or keying. */
  onValueChange?: (value: number, details: ChangeDetails) => void;
  /** Fires once when an interaction ends. With inertia, that is when the wheel settles. */
  onValueCommitted?: (value: number, details: ChangeDetails) => void;
  /** Covers pointer, scroll and keyboard interactions, including any coast. */
  onInteractionChange?: (active: boolean, details: InteractionDetails) => void;
  /** Fires when a pointer takes hold of the ring and when it lets go. Use `onInteractionChange` to include coasting and cancellation. */
  onDraggingChange?: (dragging: boolean) => void;
  /** Fires each time the value crosses a detent. */
  onTick?: (direction: 1 | -1) => void;
  min?: number;
  max?: number;
  /** Granularity of emitted values; also the arrow-key step. */
  step?: number;
  /** The gearing: how many units one full revolution covers. */
  unitsPerTurn?: number;
  /** Units between detents (haptic pulse + onTick). 0 turns detents off. */
  detent?: number;
  /** Pulse the haptic motor when crossing detents; coalesce crossings within one update. */
  haptics?: boolean;
  /** Keep spinning after a flick and slow down like an iOS scroll. */
  inertia?: boolean;
  /** Velocity kept per millisecond while coasting. 0.998 is iOS normal; 0.99 stops fast. */
  decelerationRate?: number;
  disabled?: boolean;
  /** Renders a hidden input with this name, for forms. */
  name?: string;
}

/**
 * A rotary slider. Drag around the ring; one revolution moves the value by
 * `unitsPerTurn`, so range and precision are set by gearing instead of by
 * how many pixels of screen the control happens to span.
 */
export function Root(props: RootProps) {
  const {
    value: valueProp,
    defaultValue,
    onValueChange,
    onValueCommitted,
    onInteractionChange,
    onDraggingChange,
    onTick,
    min = 0,
    max = 100,
    step = 1,
    unitsPerTurn = 100,
    detent = 0,
    haptics = true,
    inertia = false,
    decelerationRate = DEFAULT_DECELERATION_RATE,
    disabled = false,
    name,
    children,
    ref: refProp,
    ...rest
  } = props;

  const [internalValue, setInternalValue] = React.useState(() =>
    clamp(defaultValue ?? min, min, max),
  );
  const value = clamp(valueProp ?? internalValue, min, max);
  const [dragging, setDragging] = React.useState(false);
  const [coasting, setCoasting] = React.useState(false);
  const [progressRevision, syncProgress] = React.useReducer((revision: number) => revision + 1, 0);

  const rootRef = React.useRef<HTMLDivElement>(null);
  const ringRef = React.useRef<HTMLElement>(null);
  const centerRef = React.useRef<HTMLElement>(null);
  const mergedRootRef = useMergedRefs(rootRef, refProp);

  // Gesture state lives outside React: pointermove must not wait for renders.
  const g = React.useRef({
    pointerId: -1,
    cx: 0,
    cy: 0,
    lastAngle: 0,
    float: 0,
    rotation: 0,
    emitted: NaN,
    dragging: false,
    coasting: false,
    wheeling: false,
    interaction: null as InteractionSource | null,
    wheelTimer: 0,
    samples: [] as Sample[],
    velocity: 0,
    frame: 0,
    lastFrame: 0,
    onWindowBlur: null as null | (() => void),
  });

  // Handlers and the coast loop read props from here, so a frame that runs
  // between renders never sees stale gearing or callbacks.
  const propsSnapshot = {
    value,
    controlled: valueProp !== undefined,
    min,
    max,
    step,
    unitsPerTurn,
    detent,
    haptics,
    inertia,
    decelerationRate,
    disabled,
    onValueChange,
    onValueCommitted,
    onInteractionChange,
    onDraggingChange,
    onTick,
  };
  const latest = React.useRef(propsSnapshot);
  React.useLayoutEffect(() => {
    latest.current = propsSnapshot;
  });

  const beginInteraction = (source: InteractionSource) => {
    if (g.current.interaction) return;
    g.current.interaction = source;
    latest.current.onInteractionChange?.(true, { source, cancelled: false });
  };

  const endInteraction = (cancelled = false) => {
    const source = g.current.interaction;
    if (!source) return;
    g.current.interaction = null;
    latest.current.onInteractionChange?.(false, { source, cancelled });
    syncProgress();
  };

  const emit = (raw: number, commit: boolean) => {
    const L = latest.current;
    const next = stepValue(raw, L.min, L.max, L.step);
    const details = { source: g.current.interaction ?? "keyboard" };
    if (next !== g.current.emitted) {
      g.current.emitted = next;
      if (!L.controlled) setInternalValue(next);
      L.onValueChange?.(next, details);
    }
    if (commit) {
      L.onValueCommitted?.(next, details);
      endInteraction();
    }
  };

  const spin = (deltaDeg: number) => {
    g.current.rotation += deltaDeg;
    rootRef.current?.style.setProperty("--click-wheel-rotation", `${g.current.rotation}deg`);
  };

  /**
   * The progress variables. Written from the continuous position during a
   * turn, so arcs and grooves move like a needle, not in value steps.
   */
  const writeProgress = (units: number) => {
    const L = latest.current;
    const root = rootRef.current;
    if (!root) return;
    const fraction = L.max > L.min ? (units - L.min) / (L.max - L.min) : 0;
    root.style.setProperty("--click-wheel-fraction", String(fraction));
    root.style.setProperty("--click-wheel-turns", String((units - L.min) / L.unitsPerTurn));
  };

  const crossDetents = (prev: number, next: number) => {
    const L = latest.current;
    const crossings = detentCrossings(prev, next, L.min, L.detent);
    if (crossings === 0) return;
    if (L.haptics) haptic();
    const direction = crossings > 0 ? 1 : -1;
    if (L.onTick) {
      for (let i = 0; i < Math.abs(crossings); i++) L.onTick(direction);
    }
  };

  /** Turn the ring by an angle: rotate the rotor, move the value, tick detents. */
  const turnBy = (deltaDeg: number) => {
    const L = latest.current;
    const prev = g.current.float;
    const next = clamp(prev + degreesToUnits(deltaDeg, L.unitsPerTurn), L.min, L.max);
    spin(unitsToDegrees(next - prev, L.unitsPerTurn));
    g.current.float = next;
    writeProgress(next);
    crossDetents(prev, next);
    emit(next, false);
  };

  const beginDrag = () => {
    if (g.current.dragging) return;
    g.current.dragging = true;
    setDragging(true);
    latest.current.onDraggingChange?.(true);
  };

  const endDrag = () => {
    if (!g.current.dragging) return;
    g.current.dragging = false;
    setDragging(false);
    latest.current.onDraggingChange?.(false);
  };

  const setCoast = (next: boolean) => {
    if (g.current.coasting === next) return;
    g.current.coasting = next;
    setCoasting(next);
  };

  /** The interaction is over: the pointer lifted with no flick, or the coast ran out. */
  const settle = () => {
    window.clearTimeout(g.current.wheelTimer);
    g.current.wheeling = false;
    if (g.current.onWindowBlur) {
      window.removeEventListener("blur", g.current.onWindowBlur);
      g.current.onWindowBlur = null;
    }
    g.current.velocity = 0;
    endDrag();
    setCoast(false);
    emit(g.current.float, true);
  };

  const cancelCoast = () => {
    if (!g.current.frame) return;
    cancelAnimationFrame(g.current.frame);
    g.current.frame = 0;
    g.current.velocity = 0;
    setCoast(false);
  };

  const cancelInteraction = () => {
    const s = g.current;
    if (!s.interaction) return;
    const pointerId = s.pointerId;
    s.pointerId = -1;
    cancelCoast();
    window.clearTimeout(s.wheelTimer);
    s.wheeling = false;
    if (s.onWindowBlur) {
      window.removeEventListener("blur", s.onWindowBlur);
      s.onWindowBlur = null;
    }
    endDrag();
    if (pointerId !== -1 && ringRef.current?.hasPointerCapture?.(pointerId)) {
      ringRef.current.releasePointerCapture(pointerId);
    }
    endInteraction(true);
  };

  /** Stop a coast from the outside (scroll, keys): the wheel settles where it is. */
  const interrupt = (includeWheel = false) => {
    if (!g.current.frame && !(includeWheel && g.current.wheeling)) return;
    cancelCoast();
    settle();
  };

  /** Spin on with `velocity` in degrees per millisecond, losing speed like an iOS scroll. */
  const coast = (velocity: number, startedAt: number) => {
    g.current.velocity = velocity;
    g.current.lastFrame = startedAt;
    endDrag();
    setCoast(true);
    const frame = (now: number) => {
      const L = latest.current;
      const s = g.current;
      if (L.disabled) {
        cancelInteraction();
        return;
      }
      const dt = Math.min(now - s.lastFrame, 64);
      s.lastFrame = now;
      const stepped = coastStep(s.velocity, dt, L.decelerationRate);
      s.velocity = stepped.velocity;
      turnBy(stepped.delta);
      const atEnd = (s.float <= L.min && s.velocity < 0) || (s.float >= L.max && s.velocity > 0);
      if (Math.abs(s.velocity) < MIN_VELOCITY || atEnd) {
        s.frame = 0;
        settle();
        return;
      }
      s.frame = requestAnimationFrame(frame);
    };
    g.current.frame = requestAnimationFrame(frame);
  };

  /** Move the value by whole units: keyboard and programmatic steps. */
  const nudge = (deltaUnits: number) => {
    if (g.current.dragging) cancelInteraction();
    const wasInteracting = g.current.interaction !== null;
    const previous = wasInteracting ? g.current.emitted : latest.current.value;
    interrupt(true);
    const L = latest.current;
    beginInteraction("keyboard");
    g.current.emitted = previous;
    const next = stepValue(previous + deltaUnits, L.min, L.max, L.step);
    g.current.float = next;
    spin(unitsToDegrees(next - previous, L.unitsPerTurn));
    writeProgress(next);
    crossDetents(previous, next);
    emit(next, true);
  };

  const angleOf = (e: { clientX: number; clientY: number }) =>
    angleAt(g.current.cx, g.current.cy, e.clientX, e.clientY);

  const onPointerDown = (e: React.PointerEvent) => {
    const L = latest.current;
    if (L.disabled || g.current.pointerId !== -1 || e.button !== 0) return;
    const ring = ringRef.current;
    if (!ring) return;
    if (centerRef.current?.contains(e.target as Node)) return;
    if (g.current.wheeling) interrupt(true);
    const rect = ring.getBoundingClientRect();
    const cx = rect.left + rect.width / 2;
    const cy = rect.top + rect.height / 2;
    // The angle is unstable right at the hub; ignore presses there.
    if (Math.hypot(e.clientX - cx, e.clientY - cy) < Math.min(rect.width, rect.height) * HUB_DEAD_ZONE) {
      return;
    }
    e.preventDefault();
    try {
      ring.setPointerCapture(e.pointerId);
    } catch {
      // Synthetic events in tests carry ids the browser does not know.
    }
    ring.focus({ preventScroll: true });
    // A touch grabs a spinning wheel: its momentum is gone, a hand is on it.
    const wasCoasting = Boolean(g.current.frame);
    cancelCoast();
    Object.assign(g.current, {
      pointerId: e.pointerId,
      cx,
      cy,
      float: wasCoasting ? g.current.float : L.value,
      emitted: wasCoasting ? g.current.emitted : L.value,
    });
    g.current.lastAngle = angleOf(e);
    g.current.samples = [{ t: e.timeStamp, rotation: g.current.rotation }];
    // A release outside the window never reaches the ring; the window going
    // blurry is the signal that the pointer is gone.
    const onWindowBlur = () => {
      if (g.current.pointerId === -1) return;
      cancelInteraction();
    };
    if (g.current.onWindowBlur) window.removeEventListener("blur", g.current.onWindowBlur);
    g.current.onWindowBlur = onWindowBlur;
    window.addEventListener("blur", onWindowBlur);
    beginInteraction("pointer");
    beginDrag();
  };

  const onPointerMove = (e: React.PointerEvent) => {
    if (latest.current.disabled || g.current.pointerId !== e.pointerId) return;
    const angle = angleOf(e);
    const delta = arcDelta(g.current.lastAngle, angle);
    g.current.lastAngle = angle;
    turnBy(delta);
    const samples = g.current.samples;
    samples.push({ t: e.timeStamp, rotation: g.current.rotation });
    while (samples.length > 1 && e.timeStamp - samples[0].t > VELOCITY_WINDOW_MS) samples.shift();
  };

  const onPointerUp = (e: React.PointerEvent) => {
    if (g.current.pointerId !== e.pointerId) return;
    g.current.pointerId = -1;
    const L = latest.current;
    const velocity = L.inertia && !L.disabled ? releaseVelocity(g.current.samples, e.timeStamp) : 0;
    if (Math.abs(velocity) >= MIN_VELOCITY) {
      coast(velocity, e.timeStamp); // the hand is off; the wheel coasts until it settles
      return;
    }
    settle();
  };

  // Fires on cancel, and whenever the browser drops the capture mid-turn.
  // After a normal release the pointer id is already cleared, so this no-ops.
  const onPointerCancel = (e: React.PointerEvent) => {
    if (g.current.pointerId !== e.pointerId) return;
    cancelInteraction();
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    const L = latest.current;
    if (L.disabled) return;
    const deltaUnits = keyDelta(e.key, {
      ...L,
      value: g.current.interaction && !g.current.dragging ? g.current.emitted : L.value,
    });
    if (deltaUnits === null) return;
    e.preventDefault();
    nudge(deltaUnits);
  };

  // A scroll gesture over the wheel turns it. Listens on the root so the
  // center is covered too; non-passive so the page does not scroll.
  const onWheel = React.useEffectEvent((e: WheelEvent) => {
    const L = latest.current;
    if (L.disabled) return;
    e.preventDefault();
    if (g.current.dragging) return;
    interrupt();
    const scale = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1;
    const px = (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY) * scale;
    if (!g.current.wheeling) {
      g.current.wheeling = true;
      beginInteraction("wheel");
      if (g.current.pointerId === -1) {
        g.current.float = L.value;
        g.current.emitted = L.value;
      }
    }
    turnBy(px * WHEEL_DEG_PER_PX);
    window.clearTimeout(g.current.wheelTimer);
    g.current.wheelTimer = window.setTimeout(() => {
      g.current.wheeling = false;
      if (g.current.pointerId === -1) emit(g.current.float, true);
    }, 150);
  });

  React.useEffect(() => {
    const root = rootRef.current;
    const gesture = g.current;
    if (!root) return;
    root.addEventListener("wheel", onWheel, { passive: false });
    return () => {
      root.removeEventListener("wheel", onWheel);
      window.clearTimeout(gesture.wheelTimer);
      if (gesture.frame) cancelAnimationFrame(gesture.frame);
      if (gesture.onWindowBlur) window.removeEventListener("blur", gesture.onWindowBlur);
      if (gesture.dragging) latest.current.onDraggingChange?.(false);
      if (gesture.interaction) {
        latest.current.onInteractionChange?.(false, { source: gesture.interaction, cancelled: true });
      }
      gesture.interaction = null;
      gesture.dragging = false;
      gesture.coasting = false;
      gesture.wheeling = false;
      gesture.pointerId = -1;
      gesture.frame = 0;
    };
  }, []);

  const cancelWhenDisabled = React.useEffectEvent(() => {
    if (disabled) cancelInteraction();
  });
  React.useLayoutEffect(() => {
    cancelWhenDisabled();
  }, [disabled]);

  // Keep continuous gesture progress until it settles, then use the accepted value.
  React.useLayoutEffect(() => {
    if (g.current.interaction) return;
    const root = rootRef.current;
    if (!root) return;
    root.style.setProperty("--click-wheel-fraction", String(max > min ? (value - min) / (max - min) : 0));
    root.style.setProperty("--click-wheel-turns", String((value - min) / unitsPerTurn));
  }, [value, min, max, unitsPerTurn, dragging, coasting, progressRevision]);

  // The first paint gets the variables inline; after that they are written
  // directly, so React never overwrites a position mid-turn.
  const [initialVars] = React.useState(() => ({
    "--click-wheel-fraction": max > min ? (value - min) / (max - min) : 0,
    "--click-wheel-turns": (value - min) / unitsPerTurn,
  }) as React.CSSProperties);

  const state: ClickWheelState = { value, dragging, coasting, disabled };
  const context: ClickWheelContextValue = {
    state,
    min,
    max,
    step,
    ringRef,
    centerRef,
    onPointerDown,
    onPointerMove,
    onPointerUp,
    onPointerCancel,
    onKeyDown,
  };

  return (
    <ClickWheelContext.Provider value={context}>
      {useRenderPart(
        "div",
        state,
        {
          ...rest,
          children: (
            <>
              {children}
              {name ? <input type="hidden" name={name} value={value} disabled={disabled} /> : null}
            </>
          ),
        },
        {
          ref: mergedRootRef,
          "data-dragging": dragging ? "" : undefined,
          "data-coasting": coasting ? "" : undefined,
          "data-disabled": disabled ? "" : undefined,
          style: initialVars,
        },
      )}
    </ClickWheelContext.Provider>
  );
}
```

### components/click-wheel/ring.tsx

```tsx
"use client";

import * as React from "react";
import { useClickWheelContext, type ClickWheelState } from "./context";
import { stepValue } from "./core";
import { useRenderPart, useMergedRefs, type PartProps } from "./render";

export interface RingProps extends PartProps<ClickWheelState, "div"> {
  /** Formats the value for assistive tech, e.g. seconds to "1 min 20 sec". */
  getAriaValueText?: (value: number) => string;
}

// Needed for pointer capture on touch screens; everything else is yours.
const RING_STYLE: React.CSSProperties = {
  touchAction: "none",
  userSelect: "none",
  WebkitUserSelect: "none",
  WebkitTouchCallout: "none",
};

/**
 * The interactive ring. Drag around it, scroll over it, or focus it and use
 * the keyboard. Exposed to assistive tech as a slider.
 */
export function Ring(props: RingProps) {
  const { getAriaValueText, ref: refProp, ...rest } = props;
  const ctx = useClickWheelContext("Ring");
  const { state, min, max, step } = ctx;
  const spoken = stepValue(state.value, min, max, step);
  const ref = useMergedRefs(ctx.ringRef, refProp as React.Ref<HTMLElement>);

  return useRenderPart("div", state, rest, {
    ref,
    role: "slider",
    tabIndex: state.disabled ? -1 : 0,
    "aria-valuemin": min,
    "aria-valuemax": max,
    "aria-valuenow": spoken,
    "aria-valuetext": getAriaValueText?.(spoken),
    "aria-disabled": state.disabled || undefined,
    "data-dragging": state.dragging ? "" : undefined,
    "data-coasting": state.coasting ? "" : undefined,
    "data-disabled": state.disabled ? "" : undefined,
    style: RING_STYLE,
    onPointerDown: ctx.onPointerDown,
    onPointerMove: ctx.onPointerMove,
    onPointerUp: ctx.onPointerUp,
    onPointerCancel: ctx.onPointerCancel,
    onLostPointerCapture: ctx.onPointerCancel,
    onKeyDown: ctx.onKeyDown,
  });
}
```

### components/click-wheel/rotor.tsx

```tsx
"use client";

import { useClickWheelContext, type ClickWheelState } from "./context";
import { useRenderPart, type PartProps } from "./render";

export type RotorProps = PartProps<ClickWheelState, "div">;

// `will-change` keeps the rotor on its own compositor layer, so a turn moves a
// cached texture instead of repainting it on every pointer sample.
const ROTOR_STYLE = {
  rotate: "var(--click-wheel-rotation, 0deg)",
  willChange: "transform",
} as const;

/**
 * A decorative element that rotates 1:1 with the finger, like the texture of
 * a real wheel, stopping at the value bounds. Give it a texture; it does the turning.
 */
export function Rotor(props: RotorProps) {
  const { state } = useClickWheelContext("Rotor");
  return useRenderPart("div", state, props, {
    "aria-hidden": true,
    "data-dragging": state.dragging ? "" : undefined,
    "data-coasting": state.coasting ? "" : undefined,
    "data-disabled": state.disabled ? "" : undefined,
    style: ROTOR_STYLE,
  });
}
```

### components/click-wheel/center.tsx

```tsx
"use client";

import * as React from "react";
import { useClickWheelContext, type ClickWheelState } from "./context";
import { useRenderPart, useMergedRefs, type PartProps } from "./render";

export type CenterProps = PartProps<ClickWheelState, "button">;

/**
 * The center button. Presses here never start a turn. Pass `onClick` and an
 * `aria-label`; render it as a `div` for a plain hub.
 */
export function Center(props: CenterProps) {
  const { ref: refProp, ...rest } = props;
  const ctx = useClickWheelContext("Center");
  const { state } = ctx;
  const ref = useMergedRefs(ctx.centerRef, refProp as React.Ref<HTMLElement>);

  const element = useRenderPart("button", state, rest, {
    ref,
    disabled: state.disabled || undefined,
    "data-dragging": state.dragging ? "" : undefined,
    "data-coasting": state.coasting ? "" : undefined,
    "data-disabled": state.disabled ? "" : undefined,
  });
  const merged = element.props as React.ComponentPropsWithRef<"button">;
  const disabled = state.disabled || merged.disabled;
  const button = element.type === "button" || typeof element.type !== "string";

  return React.cloneElement(element, {
    type: merged.type ?? (button ? "button" : undefined),
    disabled: button ? disabled || undefined : undefined,
    "aria-disabled": disabled || merged["aria-disabled"],
    "data-disabled": disabled ? "" : undefined,
    tabIndex: disabled && !button ? -1 : merged.tabIndex,
    onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
      if (disabled) {
        event.preventDefault();
        event.stopPropagation();
        return;
      }
      merged.onClick?.(event);
    },
  } as React.ComponentPropsWithRef<"button">);
}
```

### components/click-wheel/core.ts

```ts
export const DEG = 180 / Math.PI;
/** How far a scroll gesture turns the ring: 900px of scrolling is one revolution. */
export const WHEEL_DEG_PER_PX = 0.4;
/** Pointer samples younger than this feed the release velocity. */
export const VELOCITY_WINDOW_MS = 100;
/** A finger that paused this long before lifting does not flick. */
export const RELEASE_PAUSE_MS = 60;
/** Below this the coast is over, in degrees per millisecond. */
export const MIN_VELOCITY = 0.02;
/** Velocity kept per millisecond while coasting; the iOS default. */
export const DEFAULT_DECELERATION_RATE = 0.998;
/** Presses closer to the hub than this fraction of the size are ignored: the angle is unstable there. */
export const HUB_DEAD_ZONE = 0.05;

export interface Sample {
  t: number;
  rotation: number;
}

export function clamp(v: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, v));
}

/** Angle of a point around a center, in degrees. */
export function angleAt(cx: number, cy: number, x: number, y: number): number {
  return Math.atan2(y - cy, x - cx) * DEG;
}

/** Shortest signed arc from one angle to another, in degrees. */
export function arcDelta(from: number, to: number): number {
  return ((to - from + 540) % 360) - 180;
}

export function degreesToUnits(deg: number, unitsPerTurn: number): number {
  return (deg / 360) * unitsPerTurn;
}

export function unitsToDegrees(units: number, unitsPerTurn: number): number {
  return (units / unitsPerTurn) * 360;
}

/** Snap a raw value to the step grid and the bounds. */
export function stepValue(raw: number, min: number, max: number, step: number): number {
  if (raw <= min) return min;
  if (raw >= max) return max;
  const stepped = Math.round((raw - min) / step) * step + min;
  return Number(clamp(stepped, min, max).toFixed(6));
}

/** 1 or -1 when moving from `prev` to `next` crosses a detent, else 0. */
export function detentCrossing(prev: number, next: number, min: number, detent: number): 1 | -1 | 0 {
  return Math.sign(detentCrossings(prev, next, min, detent)) as 1 | -1 | 0;
}

/** Signed number of detent boundaries crossed in one update. */
export function detentCrossings(prev: number, next: number, min: number, detent: number): number {
  if (detent <= 0 || prev === next) return 0;
  const from = Math.floor((prev - min) / detent);
  const to = Math.floor((next - min) / detent);
  return to - from;
}

/**
 * One frame of coasting under exponential decay: the distance covered in
 * `dt` milliseconds, integrated exactly, and the velocity left afterwards.
 */
export function coastStep(
  velocity: number,
  dt: number,
  decelerationRate: number,
): { delta: number; velocity: number } {
  const keep = Math.pow(decelerationRate, dt);
  const delta = (velocity * (1 - keep)) / -Math.log(decelerationRate);
  return { delta, velocity: velocity * keep };
}

/** Angular velocity at release, in degrees per millisecond, from recent samples. */
export function releaseVelocity(samples: Sample[], now: number): number {
  const last = samples[samples.length - 1];
  const first = samples[0];
  if (!last || !first || last === first || now - last.t > RELEASE_PAUSE_MS) return 0;
  const dt = last.t - first.t;
  return dt > 0 ? (last.rotation - first.rotation) / dt : 0;
}

/**
 * Angular velocity, in degrees per millisecond, of a point at offset
 * (rx, ry) from the center moving at (vx, vy) pixels per millisecond.
 */
export function angularVelocity(rx: number, ry: number, vx: number, vy: number): number {
  const r2 = rx * rx + ry * ry;
  if (r2 === 0) return 0;
  return ((rx * vy - ry * vx) / r2) * DEG;
}

export interface KeyContext {
  value: number;
  min: number;
  max: number;
  step: number;
  unitsPerTurn: number;
}

/** The change in units a keyboard key asks for, or null for keys the wheel ignores. */
export function keyDelta(key: string, ctx: KeyContext): number | null {
  const page = Math.max(ctx.step, ctx.unitsPerTurn / 6);
  if (key === "ArrowRight" || key === "ArrowUp") return ctx.step;
  if (key === "ArrowLeft" || key === "ArrowDown") return -ctx.step;
  if (key === "PageUp") return page;
  if (key === "PageDown") return -page;
  if (key === "Home") return ctx.min - ctx.value;
  if (key === "End") return ctx.max - ctx.value;
  return null;
}
```

### components/click-wheel-shadcn/wheel.tsx

```tsx
"use client";

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

const GROOVE = 4;
const MAX_GROOVES = 8;

export interface WheelProps extends Omit<ClickWheel.RootProps, "children" | "className"> {
  label: string;
  getAriaValueText?: (value: number) => string;
  icon?: React.ReactNode;
  onCenterClick?: () => void;
  centerLabel?: string;
  className?: string;
  grooves?: boolean;
}

export function Wheel({
  label,
  getAriaValueText,
  icon,
  onCenterClick,
  centerLabel,
  className,
  grooves: wantGrooves = false,
  min = 0,
  max = 100,
  unitsPerTurn = 100,
  ...rootProps
}: WheelProps) {
  const laps = Math.ceil((max - min) / unitsPerTurn - 1e-9);
  const grooves = wantGrooves && laps > 1 && laps <= MAX_GROOVES ? laps : 1;

  return (
    <ClickWheel.Root
      {...rootProps}
      min={min}
      max={max}
      unitsPerTurn={unitsPerTurn}
      className={`relative aspect-square ${className ?? ""}`}
    >
      {Array.from({ length: grooves }, (_, i) => (
        <div
          key={i}
          aria-hidden="true"
          className="absolute rounded-full [background:conic-gradient(var(--primary)_calc(var(--groove-fill)*360deg),var(--border)_0)] [mask:radial-gradient(circle_closest-side,transparent_calc(100%_-_3px),black_calc(100%_-_2px))]"
          style={
            {
              inset: i * GROOVE,
              "--groove-fill":
                grooves === 1
                  ? "var(--click-wheel-fraction)"
                  : `clamp(0, calc(var(--click-wheel-turns) - ${i}), 1)`,
            } as React.CSSProperties
          }
        />
      ))}
      <ClickWheel.Ring
        aria-label={label}
        getAriaValueText={getAriaValueText}
        style={{ inset: grooves * GROOVE + 4 }}
        className="absolute cursor-grab rounded-full border border-border bg-muted shadow-sm outline-none ring-offset-2 ring-offset-background focus-visible:ring-2 focus-visible:ring-ring data-[dragging]:cursor-grabbing data-[disabled]:cursor-default data-[disabled]:opacity-50"
      >
        <ClickWheel.Rotor className="absolute inset-0 rounded-full opacity-25 transition-opacity [background:repeating-conic-gradient(var(--ticks,var(--foreground))_0_1deg,transparent_1deg_15deg)] [mask:radial-gradient(circle_closest-side,transparent_56%,black_57%_82%,transparent_83%)] data-[dragging]:opacity-60 data-[coasting]:opacity-60" />
      </ClickWheel.Ring>
      <ClickWheel.Center
        aria-label={centerLabel}
        onClick={onCenterClick}
        className="absolute left-1/2 top-1/2 flex size-[38%] -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-border [background:var(--hub,var(--background))] [color:var(--hub-foreground,var(--foreground))] shadow-xs outline-none transition-transform active:scale-95 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 [&>svg]:size-[36%]"
      >
        {icon}
      </ClickWheel.Center>
    </ClickWheel.Root>
  );
}
```

### components/click-wheel-ipod/wheel.tsx

```tsx
"use client";

import * as React from "react";
import { ClickWheel } from "click-wheel";
import "./ipod.css";

export interface WheelProps extends Omit<ClickWheel.RootProps, "children" | "className"> {
  label: string;
  getAriaValueText?: (value: number) => string;
  icon?: React.ReactNode;
  onCenterClick?: () => void;
  centerLabel?: string;
  className?: string;
}

export function Wheel({
  label,
  getAriaValueText,
  icon,
  onCenterClick,
  centerLabel,
  className,
  ...rootProps
}: WheelProps) {
  return (
    <ClickWheel.Root {...rootProps} className={`ipod-wheel ${className ?? ""}`}>
      <ClickWheel.Ring aria-label={label} getAriaValueText={getAriaValueText} className="ipod-ring">
        <ClickWheel.Rotor className="ipod-rotor" />
        <span className="ipod-print" data-at="top">
          MENU
        </span>
        <span className="ipod-print" data-at="left">
          <svg viewBox="0 0 16 16" aria-hidden="true">
            <path d="M2 3h2v10H2zM14 3v10L8.5 8zM9 3v10L3.5 8z" />
          </svg>
        </span>
        <span className="ipod-print" data-at="right">
          <svg viewBox="0 0 16 16" aria-hidden="true">
            <path d="M12 3h2v10h-2zM2 3v10L7.5 8zM7 3v10L12.5 8z" />
          </svg>
        </span>
        <span className="ipod-print" data-at="bottom">
          <svg viewBox="0 0 16 16" aria-hidden="true">
            <path d="M2 3v10l6-5zM10 3h2v10h-2zM13 3h2v10h-2z" />
          </svg>
        </span>
      </ClickWheel.Ring>
      <ClickWheel.Center aria-label={centerLabel} onClick={onCenterClick} className="ipod-center">
        {icon}
      </ClickWheel.Center>
    </ClickWheel.Root>
  );
}
```

### components/click-wheel-ipod/ipod.css

```css
.ipod-wheel {
  --ipod-shell: #efefed;
  --ipod-shell-2: #dcdcd9;
  --ipod-hub: #dcdcd9;
  --ipod-hub-2: #c8c8c5;
  --ipod-print: #8e8f92;
  --ipod-line: rgba(0, 0, 0, 0.1);
  --ipod-gloss: rgba(255, 255, 255, 0.9);
  --ipod-focus: rgba(0, 122, 255, 0.55);
  position: relative;
  aspect-ratio: 1;
}

.dark .ipod-wheel {
  --ipod-shell: #2e2e30;
  --ipod-shell-2: #1f1f21;
  --ipod-hub: #1e1e20;
  --ipod-hub-2: #121214;
  --ipod-print: #8c8c90;
  --ipod-line: rgba(255, 255, 255, 0.08);
  --ipod-gloss: rgba(255, 255, 255, 0.14);
}

.ipod-ring {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  cursor: grab;
  outline: none;
  background: radial-gradient(circle at 50% 36%, var(--ipod-shell) 0%, var(--ipod-shell-2) 100%);
  box-shadow:
    inset 0 1px 0 var(--ipod-gloss),
    inset 0 -3px 5px rgba(0, 0, 0, 0.07),
    0 1px 2px rgba(0, 0, 0, 0.1);
}

.ipod-ring:focus-visible {
  box-shadow:
    inset 0 1px 0 var(--ipod-gloss),
    0 0 0 3px var(--ipod-focus);
}

.ipod-ring[data-dragging] {
  cursor: grabbing;
}

.ipod-ring[data-disabled] {
  cursor: default;
  opacity: 0.6;
}

.ipod-rotor {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: conic-gradient(
    from 0deg,
    transparent 0 38%,
    var(--ipod-gloss) 50%,
    transparent 62%
  );
  opacity: 0;
  transition: opacity 0.25s ease;
}

.ipod-rotor[data-dragging],
.ipod-rotor[data-coasting] {
  opacity: 0.6;
}

.ipod-print {
  position: absolute;
  display: flex;
  color: var(--ipod-print);
  font: 700 10px/1 var(--font-sans);
  letter-spacing: 0.14em;
  pointer-events: none;
}

.ipod-print svg {
  width: 13px;
  height: 13px;
  fill: currentColor;
}

.ipod-print[data-at="top"] {
  top: 10%;
  left: 50%;
  translate: -50% -50%;
}

.ipod-print[data-at="bottom"] {
  bottom: 10%;
  left: 50%;
  translate: -50% 50%;
}

.ipod-print[data-at="left"] {
  left: 10%;
  top: 50%;
  translate: -50% -50%;
}

.ipod-print[data-at="right"] {
  right: 10%;
  top: 50%;
  translate: 50% -50%;
}

.ipod-center {
  position: absolute;
  left: 50%;
  top: 50%;
  translate: -50% -50%;
  width: 37%;
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  border: 1px solid var(--ipod-line);
  color: var(--ipod-print);
  background: radial-gradient(circle at 50% 30%, var(--ipod-hub) 0%, var(--ipod-hub-2) 100%);
  box-shadow:
    inset 0 1px 0 var(--ipod-gloss),
    inset 0 -2px 3px rgba(0, 0, 0, 0.08);
  cursor: pointer;
  outline: none;
  -webkit-tap-highlight-color: transparent;
}

.ipod-center > svg {
  width: 36%;
  height: 36%;
}

.ipod-center:active {
  background: radial-gradient(circle at 50% 75%, var(--ipod-hub-2) 0%, var(--ipod-hub) 100%);
  box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.16);
}

.ipod-center:focus-visible {
  box-shadow: 0 0 0 3px var(--ipod-focus);
}

.ipod-center:disabled {
  cursor: default;
  opacity: 0.6;
}
```

### components/click-wheel-retro/wheel.tsx

```tsx
"use client";

import * as React from "react";
import { ClickWheel } from "click-wheel";
import "./retro.css";

export interface WheelProps extends Omit<ClickWheel.RootProps, "children" | "className"> {
  label: string;
  getAriaValueText?: (value: number) => string;
  icon?: React.ReactNode;
  onCenterClick?: () => void;
  centerLabel?: string;
  className?: string;
}

export function Wheel({
  label,
  getAriaValueText,
  icon,
  onCenterClick,
  centerLabel,
  className,
  ...rootProps
}: WheelProps) {
  return (
    <ClickWheel.Root {...rootProps} className={`retro-wheel ${className ?? ""}`}>
      <div className="retro-scale" aria-hidden="true" />
      <ClickWheel.Ring aria-label={label} getAriaValueText={getAriaValueText} className="retro-ring">
        <ClickWheel.Rotor className="retro-rotor" />
      </ClickWheel.Ring>
      <ClickWheel.Center aria-label={centerLabel} onClick={onCenterClick} className="retro-center">
        {icon}
      </ClickWheel.Center>
    </ClickWheel.Root>
  );
}
```

### components/click-wheel-retro/retro.css

```css
.retro-wheel {
  --retro-face: #e6e6e2;
  --retro-face-hi: #f5f5f2;
  --retro-face-lo: #d3d3ce;
  --retro-ink: #1f1f1d;
  --retro-light: rgba(255, 255, 255, 0.95);
  --retro-shade: rgba(40, 40, 36, 0.2);
  position: relative;
  aspect-ratio: 1;
}

.dark .retro-wheel {
  --retro-face: #2c2c2c;
  --retro-face-hi: #383838;
  --retro-face-lo: #212121;
  --retro-ink: #ececea;
  --retro-light: rgba(255, 255, 255, 0.07);
  --retro-shade: rgba(0, 0, 0, 0.6);
}

[data-accent="peacock"] {
  --retro-accent: #00939b;
}

[data-accent="scarlet"] {
  --retro-accent: #cb2f43;
}

[data-accent="ocher"] {
  --retro-accent: #e2b540;
}

[data-accent="sea"] {
  --retro-accent: #00b49b;
}

.retro-wheel::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: var(--retro-face);
  box-shadow:
    inset 7px 7px 14px var(--retro-shade),
    inset -7px -7px 14px var(--retro-light);
}

.retro-scale {
  position: absolute;
  inset: 3%;
  border-radius: 50%;
  background: conic-gradient(
    var(--retro-accent, #00939b) calc(var(--click-wheel-fraction) * 360deg),
    var(--retro-ink) 0
  );
  opacity: 0.85;
  mask:
    repeating-conic-gradient(#000 0 1.4deg, transparent 1.4deg 15deg),
    radial-gradient(circle closest-side, transparent 88%, #000 89%);
  mask-composite: intersect;
  -webkit-mask-composite: source-in;
}

.retro-ring {
  position: absolute;
  inset: 15%;
  border-radius: 50%;
  cursor: grab;
  outline: none;
  background: linear-gradient(145deg, var(--retro-face-hi) 0%, var(--retro-face) 55%, var(--retro-face-lo) 100%);
  box-shadow:
    12px 12px 24px var(--retro-shade),
    -10px -10px 22px var(--retro-light);
  transition: box-shadow 120ms ease;
}

.retro-ring:focus-visible {
  box-shadow:
    12px 12px 24px var(--retro-shade),
    -10px -10px 22px var(--retro-light),
    0 0 0 3px var(--retro-accent, #00939b);
}

.retro-ring[data-dragging] {
  cursor: grabbing;
  box-shadow:
    6px 6px 12px var(--retro-shade),
    -5px -5px 12px var(--retro-light);
}

.retro-ring[data-disabled] {
  cursor: default;
  opacity: 0.5;
}

.retro-rotor {
  position: absolute;
  inset: 0;
  border-radius: 50%;
}

.retro-rotor::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 8%;
  width: 9%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: var(--retro-accent, #00939b);
  box-shadow: inset 1px 1px 2px rgba(0, 0, 0, 0.25);
  translate: -50% 0;
}

.retro-center {
  position: absolute;
  left: 50%;
  top: 50%;
  translate: -50% -50%;
  width: 34%;
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 50%;
  background: linear-gradient(145deg, var(--retro-face-hi), var(--retro-face-lo));
  color: var(--retro-ink);
  box-shadow:
    6px 6px 12px var(--retro-shade),
    -5px -5px 12px var(--retro-light);
  cursor: pointer;
  outline: none;
  transition: box-shadow 80ms ease;
  -webkit-tap-highlight-color: transparent;
}

.retro-center > svg {
  width: 36%;
  height: 36%;
}

.retro-center:active {
  background: linear-gradient(145deg, var(--retro-face-lo), var(--retro-face-hi));
  box-shadow:
    inset 5px 5px 10px var(--retro-shade),
    inset -4px -4px 9px var(--retro-light);
}

.retro-center:active > * {
  transform: translateY(1px);
}

.retro-center:focus-visible {
  box-shadow:
    6px 6px 12px var(--retro-shade),
    -5px -5px 12px var(--retro-light),
    0 0 0 3px var(--retro-accent, #00939b);
}

.retro-center:disabled {
  cursor: default;
  opacity: 0.5;
}
```

### components/click-wheel-galley/wheel.tsx

```tsx
"use client";

import * as React from "react";
import { ClickWheel } from "click-wheel";
import "./galley.css";

export interface WheelProps extends Omit<ClickWheel.RootProps, "children" | "className"> {
  label: string;
  getAriaValueText?: (value: number) => string;
  icon?: React.ReactNode;
  onCenterClick?: () => void;
  centerLabel?: string;
  className?: string;
}

export function Wheel({
  label,
  getAriaValueText,
  icon,
  onCenterClick,
  centerLabel,
  className,
  ...rootProps
}: WheelProps) {
  return (
    <ClickWheel.Root {...rootProps} className={`galley-wheel ${className ?? ""}`}>
      <div className="galley-lamps" aria-hidden="true" />
      <ClickWheel.Ring aria-label={label} getAriaValueText={getAriaValueText} className="galley-ring">
        <ClickWheel.Rotor className="galley-rotor" />
      </ClickWheel.Ring>
      <ClickWheel.Center aria-label={centerLabel} onClick={onCenterClick} className="galley-center">
        {icon}
      </ClickWheel.Center>
    </ClickWheel.Root>
  );
}
```

### components/click-wheel-galley/galley.css

```css
.galley-wheel {
  --galley-metal: #e3e1dc;
  --galley-metal-2: #d2cfc8;
  --galley-sheen: #f4f2ee;
  --galley-ink: #3b3a36;
  --galley-light: rgba(255, 255, 255, 0.85);
  --galley-shade: rgba(60, 50, 35, 0.22);
  --galley-lamp: #f99d1b;
  --galley-lamp-off: rgba(60, 50, 35, 0.14);
  position: relative;
  aspect-ratio: 1;
}

.dark .galley-wheel {
  --galley-metal: #2c2d2f;
  --galley-metal-2: #222325;
  --galley-sheen: #3a3b3e;
  --galley-ink: #d9d7d1;
  --galley-light: rgba(255, 255, 255, 0.07);
  --galley-shade: rgba(0, 0, 0, 0.55);
  --galley-lamp-off: rgba(255, 255, 255, 0.08);
}

.galley-lamps {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: conic-gradient(
    var(--galley-lamp) calc(var(--click-wheel-fraction) * 360deg),
    var(--galley-lamp-off) 0
  );
  mask:
    repeating-conic-gradient(#000 0 7deg, transparent 7deg 15deg),
    radial-gradient(circle closest-side, transparent 92%, #000 93%);
  mask-composite: intersect;
  -webkit-mask-composite: source-in;
}

.galley-ring {
  position: absolute;
  inset: 11%;
  border-radius: 50%;
  cursor: grab;
  outline: none;
  background: radial-gradient(
    circle at 35% 30%,
    var(--galley-sheen) 0%,
    var(--galley-metal) 45%,
    var(--galley-metal-2) 100%
  );
  box-shadow:
    10px 10px 22px var(--galley-shade),
    -8px -8px 18px var(--galley-light),
    inset 0 0 0 1px rgba(255, 255, 255, 0.3);
}

.galley-ring:focus-visible {
  box-shadow:
    10px 10px 22px var(--galley-shade),
    -8px -8px 18px var(--galley-light),
    0 0 0 3px var(--galley-lamp);
}

.galley-ring[data-dragging] {
  cursor: grabbing;
}

.galley-ring[data-disabled] {
  cursor: default;
  opacity: 0.6;
}

.galley-rotor {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: repeating-conic-gradient(
    rgba(0, 0, 0, 0.16) 0 1deg,
    rgba(255, 255, 255, 0.45) 1deg 2deg,
    transparent 2deg 5deg
  );
  mask: radial-gradient(circle closest-side, transparent 84%, #000 85%);
}

.galley-center {
  position: absolute;
  left: 50%;
  top: 50%;
  translate: -50% -50%;
  width: 38%;
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: 50%;
  background: var(--galley-metal);
  color: var(--galley-ink);
  box-shadow:
    inset 5px 5px 10px var(--galley-shade),
    inset -4px -4px 9px var(--galley-light);
  cursor: pointer;
  outline: none;
  -webkit-tap-highlight-color: transparent;
}

.galley-center > svg {
  width: 36%;
  height: 36%;
}

.galley-center:active {
  box-shadow:
    inset 7px 7px 14px var(--galley-shade),
    inset -3px -3px 7px var(--galley-light);
}

.galley-center:active > * {
  transform: translateY(1px);
}

.galley-center:focus-visible {
  outline: 2px solid var(--galley-lamp);
  outline-offset: 3px;
}

.galley-center:disabled {
  cursor: default;
  opacity: 0.6;
}
```
