Click Wheel

Default

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

Basic

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

1:12 / 3:47

examples/basic.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.

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.

1:12

examples/gearing.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.

40 · 0 ticks
examples/detents.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
"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.

1:12 · paused

    examples/controlled.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>
      );
    }