# Knob

> A machined rotary control with a lit value arc and detents.

Category: Instruments. Docs: https://neonkit.heyaadi.com/components/knob

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/knob.json
```

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

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

## Usage

```tsx
import { Knob } from "@/components/ui/knob"

<Knob label="Cutoff" defaultValue={62} />
```

## Examples

### Synth panel

Drag up and down, use the keys, or click one and scroll. formatValue maps the 0–100 travel onto hertz and milliseconds, and the bipolar Env amt knob lights from its center detent.

```tsx
"use client";

import * as React from "react";
import { Knob } from "@/components/ui/knob";
import { cn } from "@/lib/utils";

// The knobs run 0–100; these map that travel onto musical units.
const hertz = (v: number) => {
  const f = 20 * Math.pow(1000, v / 100);
  return f < 1000 ? `${Math.round(f)} Hz` : `${(f / 1000).toFixed(f < 10000 ? 2 : 1)} kHz`;
};
const time = (v: number) => {
  const ms = Math.pow(10, v / 25);
  return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(2)} s`;
};
const percent = (v: number) => `${v}%`;
const signed = (v: number) => (v > 0 ? `+${v}` : String(v));

const filter = [
  { id: "cutoff", label: "Cutoff", size: "lg", initial: 62, format: hertz },
  { id: "resonance", label: "Reso", size: "md", initial: 35, format: percent },
  { id: "envelope", label: "Env amt", size: "md", initial: 18, min: -50, max: 50, detents: [0], format: signed },
] as const;

const envelope = [
  { id: "attack", label: "A", name: "Attack", initial: 12, format: time },
  { id: "decay", label: "D", name: "Decay", initial: 58, format: time },
  { id: "sustain", label: "S", name: "Sustain", initial: 70, format: percent },
  { id: "release", label: "R", name: "Release", initial: 64, format: time },
] as const;

type Param = (typeof filter)[number] | (typeof envelope)[number];
const all: Param[] = [...filter, ...envelope];

/** The envelope the four knobs describe, drawn like the trace on the synth's screen. */
function EnvelopeShape({ a, d, s, r }: { a: number; d: number; s: number; r: number }) {
  const w = 266;
  const h = 48;
  const segments = [12 + a * 0.7, 12 + d * 0.7, 46, 12 + r * 0.7];
  const scale = w / segments.reduce((sum, x) => sum + x, 0);
  const [aw, dw, sw] = segments.map((x) => x * scale);
  const level = h - 3 - (s / 100) * (h - 8);
  const line = `M0 ${h - 1} L${aw} 4 L${aw + dw} ${level} L${aw + dw + sw} ${level} L${w} ${h - 1}`;

  return (
    <div
      aria-hidden
      className="rounded-md border border-black/70 bg-[#05070a] bg-[linear-gradient(rgb(255_255_255/0.035)_1px,transparent_1px),linear-gradient(90deg,rgb(255_255_255/0.035)_1px,transparent_1px)] bg-[size:100%_12px,19px_100%] px-2 pb-1.5 pt-2 shadow-[inset_0_1px_4px_rgb(0_0_0/0.9),0_1px_0_rgb(255_255_255/0.05)]"
    >
      <svg viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="h-10 w-full overflow-visible">
        <path d={`${line} Z`} className="fill-[rgb(var(--neon-accent,34_211_238)/0.08)]" />
        <path
          d={line}
          fill="none"
          strokeWidth={1.25}
          strokeLinejoin="round"
          vectorEffect="non-scaling-stroke"
          className="[stroke:color-mix(in_srgb,rgb(var(--neon-accent,34_211_238))_45%,white)] [filter:drop-shadow(0_0_3px_rgb(var(--neon-accent,34_211_238)))]"
        />
      </svg>
    </div>
  );
}

function Screw({ className }: { className?: string }) {
  return (
    <span
      aria-hidden
      className={cn(
        "absolute size-2 rounded-full bg-[radial-gradient(circle_at_30%_30%,#454a57,#0f1117)] shadow-[0_1px_1px_rgb(0_0_0/0.8),0_0_0_1px_rgb(0_0_0/0.4)]",
        "after:absolute after:left-1/2 after:top-1/2 after:h-px after:w-1.5 after:-translate-x-1/2 after:-translate-y-1/2 after:rotate-[30deg] after:bg-black/80",
        className,
      )}
    />
  );
}

export default function KnobDemo() {
  const [values, setValues] = React.useState<Record<string, number>>(() =>
    Object.fromEntries(all.map((p) => [p.id, p.initial])),
  );
  const [active, setActive] = React.useState<string>("cutoff");
  const current = all.find((p) => p.id === active) ?? all[0];
  const update = (id: string) => (value: number) => {
    setActive(id);
    setValues((v) => ({ ...v, [id]: value }));
  };

  return (
    <div className="relative w-full max-w-[700px] rounded-2xl border border-white/[0.07] bg-[linear-gradient(180deg,#151821,#0b0d12)] px-3 pb-7 pt-5 shadow-[inset_0_1px_0_rgb(255_255_255/0.07),0_30px_60px_-30px_rgb(0_0_0/0.9)] sm:px-7">
      <Screw className="left-2.5 top-2.5" />
      <Screw className="right-2.5 top-2.5" />
      <Screw className="bottom-2.5 left-2.5" />
      <Screw className="bottom-2.5 right-2.5" />

      <div className="flex items-center justify-between gap-4 border-b border-white/[0.06] px-2 pb-4 sm:px-0">
        <div className="flex items-baseline gap-3">
          <span className="text-sm font-semibold tracking-[0.08em] text-white/85">NK-1</span>
          <span className="hidden text-[10px] uppercase tracking-[0.2em] text-white/50 sm:inline">
            Monophonic synthesizer
          </span>
        </div>
        <div
          aria-hidden
          className="w-40 rounded-md border border-black/70 bg-[#05070a] px-3 py-1.5 font-mono shadow-[inset_0_1px_4px_rgb(0_0_0/0.9),0_1px_0_rgb(255_255_255/0.05)]"
        >
          <div className="text-[9px] uppercase tracking-[0.2em] text-[rgb(var(--neon-accent,34_211_238))]">
            {"name" in current ? current.name : current.label}
          </div>
          <div className="text-[15px] leading-5 tabular-nums [color:color-mix(in_srgb,rgb(var(--neon-accent,34_211_238))_35%,white)] [text-shadow:0_0_6px_rgb(var(--neon-accent,34_211_238)/0.8),0_0_14px_rgb(var(--neon-accent,34_211_238)/0.4)]">
            {current.format(values[current.id])}
          </div>
        </div>
      </div>

      <div className="mt-6 flex flex-wrap items-stretch justify-center gap-x-7 gap-y-8">
        <section aria-label="Filter" className="flex w-full flex-col justify-between gap-4 sm:w-auto">
          <h3 className="text-[10px] font-medium uppercase tracking-[0.24em] text-white/50">Filter</h3>
          <div className="flex items-end justify-between gap-1.5 sm:justify-start sm:gap-5">
            {filter.map((p) => (
              <Knob
                key={p.id}
                label={p.label}
                size={p.size}
                min={"min" in p ? p.min : undefined}
                max={"max" in p ? p.max : undefined}
                detents={"detents" in p ? [...p.detents] : undefined}
                defaultValue={p.initial}
                formatValue={p.format}
                onValueChange={update(p.id)}
                onFocus={() => setActive(p.id)}
              />
            ))}
          </div>
        </section>
        <span aria-hidden className="hidden w-px bg-white/[0.06] sm:block" />
        <section aria-label="Amp envelope" className="flex w-full flex-col justify-between gap-4 sm:w-auto">
          <h3 className="text-[10px] font-medium uppercase tracking-[0.24em] text-white/50">Amp envelope</h3>
          <EnvelopeShape a={values.attack} d={values.decay} s={values.sustain} r={values.release} />
          <div className="flex items-end justify-around gap-3.5 sm:justify-start">
            {envelope.map((p) => (
              <Knob
                key={p.id}
                label={p.label}
                aria-label={p.name}
                size="sm"
                defaultValue={p.initial}
                formatValue={p.format}
                onValueChange={update(p.id)}
                onFocus={() => setActive(p.id)}
              />
            ))}
          </div>
        </section>
      </div>
    </div>
  );
}
```

### Detents and steps

Detents catch the cap while you drag or scroll, with a small spring as it lands. A knob with a dozen steps or fewer prints a mark at each one; arc narrows the sweep.

```tsx
"use client";

import { Knob } from "@/components/ui/knob";

const pan = (v: number) => (v === 0 ? "Center" : v < 0 ? `${-v} L` : `${v} R`);
const semitones = (v: number) => (v === 0 ? "0 st" : `${v > 0 ? "+" : "−"}${Math.abs(v)} st`);
const octaves = ["32′", "16′", "8′", "4′", "2′"];

export default function KnobDetents() {
  return (
    <div className="flex flex-wrap items-end justify-center gap-10">
      <Knob label="Pan" min={-50} max={50} defaultValue={0} detents={[0]} formatValue={pan} showValue />
      <Knob
        label="Detune"
        min={-24}
        max={24}
        defaultValue={7}
        detents={[-12, 0, 12]}
        formatValue={semitones}
        showValue
      />
      <Knob
        label="Octave"
        min={0}
        max={4}
        defaultValue={2}
        arc={120}
        formatValue={(i) => octaves[i]}
        showValue
      />
    </div>
  );
}
```

### In a form

Each knob with a name submits its value through a hidden input. Double-click returns a knob to its defaultValue.

```tsx
"use client";

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

const db = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v)} dB`;

export default function KnobForm() {
  const [saved, setSaved] = React.useState<string | null>(null);

  return (
    <form
      className="w-full max-w-md rounded-xl border border-white/10 bg-white/[0.03] p-6"
      onSubmit={(event) => {
        event.preventDefault();
        const data = new FormData(event.currentTarget);
        setSaved(
          `Threshold ${data.get("threshold")} dB · Ratio ${data.get("ratio")}:1 · Makeup ${data.get("makeup")} dB`,
        );
      }}
    >
      <div className="flex items-baseline justify-between">
        <h3 className="text-sm font-medium text-white/90">Vocal compressor</h3>
        <span className="text-xs text-white/40">Bus 2</span>
      </div>
      <div className="mt-6 flex justify-between gap-4">
        <Knob name="threshold" label="Threshold" min={-40} max={0} defaultValue={-18} formatValue={db} showValue />
        <Knob
          name="ratio"
          label="Ratio"
          min={1}
          max={20}
          step={0.5}
          defaultValue={4}
          detents={[2, 4, 8]}
          formatValue={(v) => `${v}:1`}
          showValue
        />
        <Knob name="makeup" label="Makeup" min={0} max={12} step={0.5} defaultValue={3} formatValue={db} showValue />
      </div>
      <div className="mt-6 flex items-center justify-between gap-4 border-t border-white/[0.06] pt-4">
        <p aria-live="polite" className="min-w-0 text-pretty text-xs leading-5 text-white/55">
          {saved ?? "Double-click a knob to reset it."}
        </p>
        <Button type="submit" size="sm">
          Save preset
        </Button>
      </div>
    </form>
  );
}
```

### Sizes, colors and disabled

Three sizes share one drawing. Disabled knobs go dark and leave the tab order.

```tsx
"use client";

import { Knob } from "@/components/ui/knob";

export default function KnobSizes() {
  return (
    <div className="flex flex-wrap items-end justify-center gap-10">
      <Knob size="sm" label="Drive" color="orange" defaultValue={72} />
      <Knob size="md" label="Mix" color="magenta" defaultValue={40} />
      <Knob size="lg" label="Volume" color="lime" defaultValue={58} formatValue={(v) => `${v}%`} showValue />
      <Knob size="md" label="Locked" defaultValue={30} disabled />
    </div>
  );
}
```

## API reference

### Knob

className and style go on the outer element. The ref and every other div attribute go on the dial, which has role="slider".

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` | — | Controlled value. |
| `defaultValue` | `number` | `min` | Initial value when uncontrolled, and where a double-click returns to. |
| `onValueChange` | `(value: number) => void` | — | Called on every change while dragging, scrolling or pressing keys. |
| `onValueCommit` | `(value: number) => void` | — | Called once a drag, key press or scroll gesture ends. |
| `min` | `number` | `0` | Lowest value. When min is below zero and max above it, the ring lights outward from zero. |
| `max` | `number` | `100` | Highest value. |
| `step` | `number` | `1` | Values snap to multiples of step from min. |
| `detents` | `number[]` | — | Values the cap clicks into while dragging or scrolling, within 3% of the range. |
| `arc` | `number` | `270` | Degrees of rotation from min to max, between 30 and 340. |
| `label` | `ReactNode` | — | Engraved legend under the dial. Names the slider unless aria-label is set. |
| `showValue` | `boolean` | `false` | Prints the formatted value under the label. |
| `formatValue` | `(value: number) => string` | — | Formats the printed value and aria-valuetext. |
| `name` | `string` | — | Submits the value with a form through a hidden input. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Dial of 56, 80 or 112px. |
| `disabled` | `boolean` | `false` | Darkens the ring, blocks input and removes the dial from the tab order. |

## Accessibility

- The dial has role="slider" with aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext when formatValue is set.
- Arrow keys move by one step, Shift+Arrow and Page Up/Down by a tenth of the range, Home and End jump to the ends.
- Drag vertically to turn it; hold Shift for fine control or Alt for coarse.
- The wheel turns a knob once it has focus (click, drag or Tab to it) and the scroll starts over it, so a page scroll never gets caught on a knob. At either end of its travel the knob lets go and the page scrolls on.
- A visible label names the slider through aria-labelledby; pass aria-label when the legend is an abbreviation, like A, D, S and R.

## Source

`components/ui/knob.tsx`

```tsx
"use client";

import * as React from "react";

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

// The dial is drawn in a 100-unit box, so every size shares one geometry.
// `travel` is how many pixels of vertical drag sweep the whole range.
const sizes = {
  sm: { dial: "size-14 text-[10px]", minors: false, tick: 2, travel: 160, label: "text-[12px]", value: "text-[11px]" },
  md: { dial: "size-20 text-[14px]", minors: true, tick: 1.4, travel: 200, label: "text-[12px]", value: "text-xs" },
  lg: { dial: "size-28 text-[19px]", minors: true, tick: 1.1, travel: 240, label: "text-[12px]", value: "text-[13px]" },
} as const;

const RING = 39;
const CORE = "color-mix(in srgb, rgb(var(--neon)) 30%, white)";

// A detent catches the cap, which rings past it for a moment like a spring ball
// dropping into its notch. The value itself lands exactly on the detent.
const settleClass = {
  none: "",
  ease: "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
  snap: "transition-transform duration-300 ease-[cubic-bezier(0.34,2.4,0.64,1)] motion-reduce:transition-none",
} as const;

function clamp(value: number, lower: number, upper: number) {
  return Math.min(upper, Math.max(lower, value));
}

function decimalsOf(value: number) {
  return (String(value).split(".")[1] ?? "").length;
}

function polar(angle: number, radius: number) {
  const rad = ((angle - 90) * Math.PI) / 180;
  return [50 + radius * Math.cos(rad), 50 + radius * Math.sin(rad)] as const;
}

function arcPath(from: number, to: number, radius: number) {
  const [x1, y1] = polar(from, radius);
  const [x2, y2] = polar(to, radius);
  const large = Math.abs(to - from) > 180 ? 1 : 0;
  const sweep = to > from ? 1 : 0;
  return `M${x1.toFixed(2)} ${y1.toFixed(2)}A${radius} ${radius} 0 ${large} ${sweep} ${x2.toFixed(2)} ${y2.toFixed(2)}`;
}

type Settle = keyof typeof settleClass;

export interface KnobProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color" | "defaultValue" | "onChange" | "children"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  size?: keyof typeof sizes;
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  /** Fires once a drag, key press or wheel gesture ends. */
  onValueCommit?: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  /** Values the cap clicks into while dragging or scrolling, like a center detent on a pan pot. */
  detents?: number[];
  /** Degrees of rotation from min to max. */
  arc?: number;
  /** Engraved legend under the dial. Also names the control. */
  label?: React.ReactNode;
  /** Prints the value under the label. */
  showValue?: boolean;
  /** Formats the printed value and the value read to screen readers. */
  formatValue?: (value: number) => string;
  /** Submits the value under this name with a surrounding form. */
  name?: string;
  disabled?: boolean;
}

const Knob = React.forwardRef<HTMLDivElement, KnobProps>(
  (
    {
      className,
      style,
      color,
      size = "md",
      value: valueProp,
      defaultValue,
      onValueChange,
      onValueCommit,
      min = 0,
      max = 100,
      step = 1,
      detents,
      arc: arcProp = 270,
      label,
      showValue = false,
      formatValue,
      name,
      disabled = false,
      id: idProp,
      "aria-label": ariaLabel,
      "aria-labelledby": ariaLabelledBy,
      onPointerDown,
      onPointerMove,
      onPointerUp,
      onPointerCancel,
      onKeyDown,
      onKeyUp,
      onDoubleClick,
      ...props
    },
    ref,
  ) => {
    const autoId = React.useId();
    const labelId = `${idProp ?? autoId}-label`;
    const dialRef = React.useRef<HTMLDivElement>(null);
    React.useImperativeHandle(ref, () => dialRef.current as HTMLDivElement);

    const [uncontrolled, setUncontrolled] = React.useState(() => clamp(defaultValue ?? min, min, max));
    const isControlled = valueProp !== undefined;
    const value = clamp(isControlled ? valueProp : uncontrolled, min, max);

    const [dragging, setDragging] = React.useState(false);
    const [settle, setSettle] = React.useState<Settle>("none");
    const drag = React.useRef<{ y: number; raw: number; start: number } | null>(null);
    const keyChanged = React.useRef(false);
    const wheel = React.useRef({ last: -Infinity, inside: false, raw: null as number | null, start: 0, latest: 0, timer: 0 });
    const wheelHandler = React.useRef<(event: WheelEvent) => void>(() => {});

    const s = sizes[size];
    const arc = clamp(arcProp, 30, 340);
    const range = max - min || 1;
    const decimals = Math.max(decimalsOf(step), decimalsOf(min));
    // Bipolar ranges (pan, detune) light from zero outwards.
    const origin = min < 0 && max > 0 ? 0 : min;
    const angleOf = (v: number) => -arc / 2 + ((clamp(v, min, max) - min) / range) * arc;
    const angle = angleOf(value);
    const originAngle = angleOf(origin);
    const litFraction = Math.abs(angle - originAngle) / arc;

    function snapToStep(raw: number) {
      return clamp(Number((Math.round((raw - min) / step) * step + min).toFixed(decimals)), min, max);
    }

    /** Lands on a detent when the raw position is close enough, otherwise on a step. */
    function resolve(raw: number) {
      const zone = range * 0.03;
      const detent = detents?.find((d) => Math.abs(raw - d) <= zone);
      return detent !== undefined ? clamp(detent, min, max) : snapToStep(raw);
    }

    function setValue(next: number, mode: Settle) {
      if (next === value) return false;
      if (!isControlled) setUncontrolled(next);
      setSettle(mode);
      onValueChange?.(next);
      return true;
    }

    /** Pointer and wheel moves: detents get the springy settle, everything else tracks 1:1. */
    function moveTo(raw: number) {
      const next = resolve(raw);
      const crossesDetent = detents?.some((d) => d === next || d === value) ?? false;
      setValue(next, crossesDetent ? "snap" : "none");
      return next;
    }

    function handlePointerDown(event: React.PointerEvent<HTMLDivElement>) {
      onPointerDown?.(event);
      if (disabled || event.defaultPrevented || event.button !== 0) return;
      // Keeps text from selecting while dragging; we focus the dial ourselves.
      event.preventDefault();
      event.currentTarget.focus({ preventScroll: true });
      event.currentTarget.setPointerCapture(event.pointerId);
      drag.current = { y: event.clientY, raw: value, start: value };
      setDragging(true);
    }

    function handlePointerMove(event: React.PointerEvent<HTMLDivElement>) {
      onPointerMove?.(event);
      const d = drag.current;
      if (!d) return;
      const factor = event.shiftKey ? 0.1 : event.altKey ? 4 : 1;
      d.raw = clamp(d.raw + ((d.y - event.clientY) * factor * range) / s.travel, min, max);
      d.y = event.clientY;
      moveTo(d.raw);
    }

    function endDrag() {
      const d = drag.current;
      if (!d) return;
      drag.current = null;
      setDragging(false);
      if (value !== d.start) onValueCommit?.(value);
    }

    function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
      onKeyDown?.(event);
      if (disabled || event.defaultPrevented) return;
      const big = Math.max(step, Math.round(range / 10 / step) * step);
      const arrow = event.shiftKey ? big : step;
      const delta: Record<string, number> = {
        ArrowUp: arrow,
        ArrowRight: arrow,
        ArrowDown: -arrow,
        ArrowLeft: -arrow,
        PageUp: big,
        PageDown: -big,
        Home: -Infinity,
        End: Infinity,
      };
      if (!(event.key in delta)) return;
      event.preventDefault();
      if (setValue(snapToStep(clamp(value + delta[event.key], min, max)), "ease")) keyChanged.current = true;
    }

    function handleKeyUp(event: React.KeyboardEvent<HTMLDivElement>) {
      onKeyUp?.(event);
      if (!keyChanged.current) return;
      keyChanged.current = false;
      onValueCommit?.(value);
    }

    function handleWheel(event: WheelEvent) {
      const gesture = wheel.current;
      if (disabled || !gesture.inside) return;
      // Shift turns the wheel sideways on some systems, so take whichever axis moved.
      const delta = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX;
      // At the end of its travel the knob lets go, and the rest of the gesture scrolls the page.
      const current = gesture.raw ?? value;
      if ((delta > 0 && current <= min) || (delta < 0 && current >= max)) {
        gesture.inside = false;
        return;
      }
      event.preventDefault();
      const pixels = event.deltaMode === 1 ? delta * 16 : delta;
      const factor = event.shiftKey ? 0.1 : event.altKey ? 4 : 1;
      if (gesture.raw === null) gesture.start = value;
      const raw = clamp((gesture.raw ?? value) - (pixels * factor * range) / 2000, min, max);
      gesture.raw = raw;
      gesture.latest = moveTo(raw);
      window.clearTimeout(gesture.timer);
      gesture.timer = window.setTimeout(() => {
        if (gesture.latest !== gesture.start) onValueCommit?.(gesture.latest);
      }, 300);
    }

    React.useEffect(() => {
      wheelHandler.current = handleWheel;
    });

    React.useEffect(() => {
      const node = dialRef.current;
      if (!node) return;
      const gesture = wheel.current;
      // The wheel belongs to the page until the knob has been picked up: only a
      // gesture that begins on a focused knob turns it, so resting the pointer on
      // one while reading never hijacks the scroll.
      const track = (event: WheelEvent) => {
        if (event.timeStamp - gesture.last > 300) {
          gesture.inside = node.contains(event.target as Node) && document.activeElement === node;
          gesture.raw = null;
        }
        gesture.last = event.timeStamp;
      };
      const turn = (event: WheelEvent) => wheelHandler.current(event);
      window.addEventListener("wheel", track, { capture: true, passive: true });
      node.addEventListener("wheel", turn, { passive: false });
      return () => {
        window.removeEventListener("wheel", track, { capture: true });
        node.removeEventListener("wheel", turn);
        window.clearTimeout(gesture.timer);
      };
    }, []);

    const text = formatValue ? formatValue(value) : String(value);
    const lit = !disabled;
    // A stepped knob with a handful of positions marks each one; otherwise a 0–10 scale.
    const positions = Math.round(range / step);
    const stepped = positions <= 12 && Math.abs(positions * step - range) < 1e-9;
    const divisions = stepped ? positions : s.minors ? 20 : 10;
    const ticks: { angle: number; major: boolean }[] = [];
    for (let i = 0; i <= divisions; i++) {
      ticks.push({ angle: -arc / 2 + (i / divisions) * arc, major: stepped || !s.minors || i % 2 === 0 });
    }
    const litFrom = Math.min(angle, originAngle);
    const litTo = Math.max(angle, originAngle);

    return (
      <div
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "inline-flex w-fit select-none flex-col items-center gap-2",
          disabled && "opacity-50",
          className,
        )}
      >
        <div
          ref={dialRef}
          id={idProp}
          role="slider"
          tabIndex={disabled ? -1 : 0}
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledBy ?? (label && !ariaLabel ? labelId : undefined)}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuenow={value}
          aria-valuetext={formatValue ? text : undefined}
          aria-disabled={disabled || undefined}
          data-state={dragging ? "dragging" : undefined}
          data-disabled={disabled ? "" : undefined}
          {...props}
          onPointerDown={handlePointerDown}
          onPointerMove={handlePointerMove}
          onPointerUp={(event) => {
            onPointerUp?.(event);
            endDrag();
          }}
          onPointerCancel={(event) => {
            onPointerCancel?.(event);
            endDrag();
          }}
          onKeyDown={handleKeyDown}
          onKeyUp={handleKeyUp}
          onDoubleClick={(event) => {
            onDoubleClick?.(event);
            if (disabled || event.defaultPrevented || defaultValue === undefined) return;
            const next = snapToStep(clamp(defaultValue, min, max));
            if (setValue(next, "snap")) onValueCommit?.(next);
          }}
          className={cn(
            "group/knob relative shrink-0 touch-none rounded-full outline-none",
            "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
            disabled ? "cursor-not-allowed" : "cursor-ns-resize",
            s.dial,
          )}
        >
          {/* Light from the ring falling on the panel around the knob */}
          <span
            aria-hidden
            className="pointer-events-none absolute -inset-[22%] rounded-full bg-[radial-gradient(closest-side,rgb(var(--neon)/CALC(0.2*var(--neon-glow,1))),rgb(var(--neon)/CALC(0.06*var(--neon-glow,1)))_60%,transparent)] transition-opacity duration-200"
            style={{ opacity: lit ? 0.25 + litFraction * 0.75 : 0 }}
          />

          {/* Printed scale and the unlit LED ring */}
          <svg aria-hidden viewBox="0 0 100 100" className="pointer-events-none absolute inset-0 size-full overflow-visible">
            {ticks.map((tick, i) => {
              const [x1, y1] = polar(tick.angle, 44.5);
              const [x2, y2] = polar(tick.angle, tick.major ? 49 : 47);
              const inLit = lit && tick.angle >= litFrom - 0.01 && tick.angle <= litTo + 0.01 && litTo > litFrom;
              return (
                <line
                  key={i}
                  x1={x1.toFixed(2)}
                  y1={y1.toFixed(2)}
                  x2={x2.toFixed(2)}
                  y2={y2.toFixed(2)}
                  strokeWidth={s.tick}
                  strokeLinecap="round"
                  className={cn(
                    "transition-[stroke] duration-150",
                    inLit ? "stroke-[rgb(var(--neon)/0.6)]" : tick.major ? "stroke-white/25" : "stroke-white/[0.13]",
                  )}
                />
              );
            })}
            {detents?.map((d) => {
              const [x, y] = polar(angleOf(d), 53);
              return (
                <circle
                  key={d}
                  cx={x.toFixed(2)}
                  cy={y.toFixed(2)}
                  r={s.tick * 1.1}
                  className={cn(
                    "transition-[fill] duration-150",
                    lit && value === d ? "fill-[rgb(var(--neon))]" : "fill-white/30",
                  )}
                />
              );
            })}
            <path
              d={arcPath(-arc / 2, arc / 2, RING)}
              fill="none"
              strokeWidth={3.4}
              strokeLinecap="round"
              className="stroke-[rgb(var(--neon)/0.13)]"
            />
          </svg>

          {/* The lit part of the ring: a colored body with a near-white core */}
          {lit && litTo - litFrom > 0.01 && (
            <svg
              aria-hidden
              viewBox="0 0 100 100"
              className={cn(
                "pointer-events-none absolute inset-0 size-full overflow-visible transition-[filter] duration-200",
                "[filter:drop-shadow(0_0_1.5px_rgb(var(--neon)/var(--neon-glow,1)))_drop-shadow(0_0_5px_rgb(var(--neon)/CALC(0.55*var(--neon-glow,1))))]",
                "group-hover/knob:[filter:drop-shadow(0_0_2px_rgb(var(--neon)/var(--neon-glow,1)))_drop-shadow(0_0_8px_rgb(var(--neon)/CALC(0.75*var(--neon-glow,1))))]",
                "group-focus-visible/knob:[filter:drop-shadow(0_0_2px_rgb(var(--neon)/var(--neon-glow,1)))_drop-shadow(0_0_8px_rgb(var(--neon)/CALC(0.75*var(--neon-glow,1))))]",
                "group-data-[state=dragging]/knob:[filter:drop-shadow(0_0_2px_rgb(var(--neon)/var(--neon-glow,1)))_drop-shadow(0_0_9px_rgb(var(--neon)/CALC(0.85*var(--neon-glow,1))))]",
              )}
            >
              <path
                d={arcPath(litFrom, litTo, RING)}
                fill="none"
                strokeWidth={3.4}
                strokeLinecap="round"
                className="stroke-[rgb(var(--neon))]"
              />
              <path
                d={arcPath(litFrom, litTo, RING)}
                fill="none"
                strokeWidth={1.3}
                strokeLinecap="round"
                style={{ stroke: CORE }}
              />
            </svg>
          )}

          {/* The cap. Lighting stays put while the knurl and pointer turn. */}
          <span
            aria-hidden
            className="pointer-events-none absolute inset-[19%] rounded-full bg-[#090a0e] shadow-[0_0.3em_0.55em_rgb(0_0_0/0.75),0_0.08em_0.12em_rgb(0_0_0/0.9)]"
          >
            <span
              className={cn("absolute inset-0 rounded-full", settleClass[settle])}
              style={{ transform: `rotate(${angle}deg)` }}
            >
              <span className="absolute inset-0 rounded-full bg-[repeating-conic-gradient(rgb(255_255_255/0.09)_0deg_2.5deg,rgb(0_0_0/0.3)_2.5deg_5deg)]" />
            </span>
            <span className="absolute inset-0 rounded-full bg-[linear-gradient(180deg,rgb(255_255_255/0.16),rgb(255_255_255/0.02)_40%,rgb(0_0_0/0.5))] shadow-[inset_0_0_0_1px_rgb(0_0_0/0.5)]" />
            <span className="absolute inset-[13%] rounded-full bg-[repeating-radial-gradient(circle,rgb(255_255_255/0.025)_0_1px,transparent_1px_2.5px),conic-gradient(from_200deg,#1c1f27,#3a3f4c_9%,#191b22_23%,#2d313c_38%,#15171d_52%,#353a46_67%,#17191f_83%,#1c1f27)] shadow-[inset_0_1px_0_rgb(255_255_255/0.14),inset_0_-1px_1px_rgb(0_0_0/0.6),0_0_0_1px_rgb(0_0_0/0.55)]" />
            <span
              className={cn("absolute inset-0 rounded-full", settleClass[settle])}
              style={{ transform: `rotate(${angle}deg)` }}
            >
              <span
                className={cn(
                  "absolute left-1/2 top-[15%] h-[27%] w-[0.14em] min-w-[1.5px] -translate-x-1/2 rounded-full transition-[background-color,box-shadow] duration-200",
                  lit
                    ? "shadow-[0_0_0.15em_rgb(var(--neon)/var(--neon-glow,1)),0_0_0.45em_rgb(var(--neon)/CALC(0.7*var(--neon-glow,1)))]"
                    : "bg-[rgb(var(--neon)/0.16)]",
                )}
                style={lit ? { backgroundColor: CORE } : undefined}
              />
            </span>
          </span>
        </div>

        {label && (
          <span
            id={labelId}
            onClick={() => {
              if (!disabled) dialRef.current?.focus();
            }}
            className={cn("font-medium leading-none tracking-[0.01em] text-white/60", s.label)}
          >
            {label}
          </span>
        )}
        {showValue && (
          <span
            aria-hidden
            className={cn(
              "leading-none tabular-nums transition-[color,text-shadow] duration-150",
              dragging
                ? "text-[rgb(var(--neon))] [text-shadow:0_0_8px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1)))]"
                : "text-white/80",
              s.value,
            )}
          >
            {text}
          </span>
        )}
        {name && <input type="hidden" name={name} value={value} disabled={disabled} />}
      </div>
    );
  },
);
Knob.displayName = "Knob";

export { Knob };
```
