Skip to content
← Components

Knob

00 layers

Source

knob.tsx
"use client";

import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { createSpring, focusQuietly, springs, type Spring } from "@/design-system";
import { play } from "@/lib/sound";

/*
 * A detented rotary control after a mixing desk's: a knurled cap seated in a
 * collar, a ring of fifteen lights around it and its value on a small LCD.
 * Every detent clicks.
 *
 * Values set from outside (recalling a scene) travel on the motor, a spring,
 * and click through each detent they pass; values set by hand follow the hand.
 * One update drives the cap, the lights, the LCD and the clicks, so what you
 * hear always matches what you see. Turning writes CSS variables, never React
 * state, and the cap turns by its gradients, not a transform.
 *
 * Drag up or right to turn it up (Shift for fine), scroll over it, or use the
 * arrow keys, Page Up and Down, Home and End.
 */

const SWEEP = 270; // degrees from the lowest detent to the highest
const SEGMENTS = 15;
const PX_PER_DETENT = 6;

type KnobProps = {
  label: string;
  value: number;
  onChange: (value: number) => void;
  min: number;
  max: number;
  /** Clicks from min to max. */
  detents?: number;
  /** "log" spaces detents evenly by ratio, for frequencies. */
  taper?: "linear" | "log";
  /** Light the ring from the centre, for controls that cut and boost. */
  bipolar?: boolean;
  format?: (value: number) => string;
  /** The motor has brought the knob to a value set from outside. */
  onSettle?: () => void;
  className?: string;
};

const cx = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(" ");

/** The ring's lights, drawn as short strokes around the cap from -135° to +135°. */
const segments = Array.from({ length: SEGMENTS }, (_, i) => {
  const a = ((-SWEEP / 2 + (i * SWEEP) / (SEGMENTS - 1) - 90) * Math.PI) / 180;
  const p = (r: number) => `${(50 + r * Math.cos(a)).toFixed(2)} ${(50 + r * Math.sin(a)).toFixed(2)}`;
  return `M${p(42.5)}L${p(47.5)}`;
});

export function Knob({ label, value, onChange, min, max, detents = 24, taper = "linear", bipolar = false, format = String, onSettle, className }: KnobProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const readoutRef = useRef<HTMLSpanElement>(null);
  const spring = useRef<Spring | null>(null);
  const index = useRef(-1); // the detent the knob is on, ahead of the render that confirms it
  const target = useRef(0); // degrees the motor is heading for
  const moving = useRef(false); // the motor is running toward a value set from outside
  const byHand = useRef<number | null>(null); // a value the hand set: follow it, don't motor to it
  const drag = useRef<{ id: number; x: number; y: number; from: number } | null>(null);
  // Props the spring and the wheel read when they fire, rather than when they were made.
  const props = useRef({ onChange, onSettle, format, min, max, detents, taper, bipolar });
  useLayoutEffect(() => {
    props.current = { onChange, onSettle, format, min, max, detents, taper, bipolar };
  });

  // The spring runs in degrees of travel and draws everything that turns: the cap, the lights, the
  // LCD. It clicks once per detent it passes, so the motor and the hand sound the same.
  useLayoutEffect(() => {
    const root = rootRef.current!;
    const lights = [...root.querySelectorAll<SVGPathElement>("[data-segment]")];
    const centre = (SEGMENTS - 1) / 2;
    let shown = -1;
    const s = createSpring(0, springs.gentle, (deg) => {
      const { detents, bipolar, format } = props.current;
      root.style.setProperty("--a", `${(deg - SWEEP / 2).toFixed(2)}deg`);
      const f = deg / SWEEP;
      const at = f * (SEGMENTS - 1);
      lights.forEach((light, i) => {
        const on = bipolar ? (at >= centre ? i >= centre && i <= at + 0.5 : i <= centre && i >= at - 0.5) : f > 0 && i <= at + 0.5;
        if (on) light.dataset.on = "";
        else delete light.dataset.on;
      });
      const detent = Math.round(f * detents);
      if (detent !== shown) {
        if (shown !== -1) play("tick", { gain: 0.55, pitch: 0.9 + (detent / detents) * 0.25 });
        shown = detent;
        if (readoutRef.current) readoutRef.current.textContent = format(valueAt(detent, props.current));
      }
      if (moving.current && deg === target.current) {
        moving.current = false;
        props.current.onSettle?.();
      }
    });
    spring.current = s;
    return () => s.stop();
  }, []);

  // A new value: the hand's lands at once, anyone else's travels on the motor.
  useLayoutEffect(() => {
    const s = spring.current!;
    const first = index.current === -1;
    const i = indexOf(value, props.current);
    index.current = i;
    target.current = (i / props.current.detents) * SWEEP;
    if (first || byHand.current === value || matchMedia("(prefers-reduced-motion: reduce)").matches) {
      moving.current = false;
      s.jump(target.current);
    } else {
      moving.current = true;
      s.set(target.current);
    }
    byHand.current = null;
  }, [value]);

  /** Moves to detent `i`; `hand` lands it at once instead of on the motor. */
  function turnTo(i: number, hand: boolean) {
    const next = Math.min(detents, Math.max(0, i));
    if (next === index.current) return play("bump", { gain: 0.5 });
    index.current = next;
    const v = valueAt(next, props.current);
    if (hand) byHand.current = v;
    onChange(v);
  }

  // Scrolling over the knob turns it; the listener is native so it can keep the page from scrolling.
  const wheel = useEffectEvent((up: boolean) => turnTo(index.current + (up ? 1 : -1), true));
  useEffect(() => {
    const el = rootRef.current!;
    let acc = 0;
    const onWheel = (e: WheelEvent) => {
      e.preventDefault();
      acc -= e.deltaY;
      if (Math.abs(acc) < 30) return;
      wheel(acc > 0);
      acc = 0;
    };
    el.addEventListener("wheel", onWheel, { passive: false });
    return () => el.removeEventListener("wheel", onWheel);
  }, []);

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const page = Math.max(1, Math.round(detents / 10));
    const delta = { ArrowUp: 1, ArrowRight: 1, ArrowDown: -1, ArrowLeft: -1, PageUp: page, PageDown: -page }[e.key];
    if (delta) turnTo(index.current + delta, false);
    else if (e.key === "Home") turnTo(0, false);
    else if (e.key === "End") turnTo(detents, false);
    else return;
    e.preventDefault();
  }

  function onPointerDown(e: PointerEvent<HTMLDivElement>) {
    if (e.button !== 0) return;
    e.preventDefault();
    focusQuietly(e.currentTarget);
    e.currentTarget.setPointerCapture(e.pointerId);
    drag.current = { id: e.pointerId, x: e.clientX, y: e.clientY, from: index.current };
    play("press", { gain: 0.6 });
  }
  function onPointerMove(e: PointerEvent<HTMLDivElement>) {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;
    // Up or right turns it up. Shift is fine control: three times the travel per detent.
    const travel = e.clientX - d.x - (e.clientY - d.y);
    const next = Math.min(detents, Math.max(0, d.from + Math.round(travel / (PX_PER_DETENT * (e.shiftKey ? 3 : 1)))));
    if (next !== index.current) turnTo(next, true);
  }
  function onPointerEnd(e: PointerEvent<HTMLDivElement>) {
    if (drag.current?.id !== e.pointerId) return;
    drag.current = null;
    play("release", { gain: 0.6 });
  }

  return (
    <div className={cx("flex flex-col items-center gap-[0.55em]", className)}>
      <div
        ref={rootRef}
        role="slider"
        tabIndex={0}
        aria-label={label}
        aria-orientation="vertical"
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={value}
        aria-valuetext={format(value)}
        onKeyDown={onKeyDown}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={onPointerEnd}
        onPointerCancel={onPointerEnd}
        className="relative size-[5.6em] cursor-ns-resize touch-none rounded-full outline-offset-2"
        style={{ "--a": `${-SWEEP / 2}deg` } as CSSProperties}
      >
        {/* The ring of lights. */}
        <svg aria-hidden data-part="light" viewBox="0 0 100 100" className="absolute inset-0 size-full">
          {segments.map((d, i) => (
            <path
              key={i}
              data-segment
              d={d}
              strokeWidth="3.4"
              strokeLinecap="round"
              className="stroke-(--device-meter-off) transition-[stroke] duration-75 data-on:stroke-(--device-meter-on) drawn:stroke-(--device-draw-line)! drawn:data-on:stroke-(--device-draw-ink)!"
            />
          ))}
        </svg>

        {/* The collar, pressed into the plate, and the cap seated in it. */}
        <div aria-hidden data-part="collar" className="absolute inset-[15%] rounded-full bg-black/[0.035] p-[5%] shadow-(--device-recess) dark:bg-black/30">
          <div data-part="cap" className="relative size-full rounded-full [background:var(--device-wheel-face)] shadow-(--device-wheel-shadow)">
            {/* Knurling round the skirt: it turns with the cap. Drawn, fine ink ticks on the same pitch. */}
            <div
              className="absolute inset-0 rounded-full drawn:[background:repeating-conic-gradient(from_var(--a),var(--device-draw-ink)_0_2.2deg,transparent_2.2deg_7.5deg)]!"
              style={{
                background: "repeating-conic-gradient(from var(--a), var(--device-meter-off) 0 1.6deg, transparent 1.6deg 7.5deg)",
                mask: "radial-gradient(circle closest-side, transparent 72%, #000 74%, #000 97%, transparent 100%)",
              }}
            />
            {/* The turned top, and its pointer. */}
            {/* Drawn, the turned top is a hairline ring inside the skirt, and the pointer a line of ink across it. */}
            <div className="absolute inset-[15%] rounded-full [background:var(--device-wheel-face)] shadow-[0_0_0_1px_rgb(0_0_0/0.06),inset_0_1px_0_rgb(255_255_255/0.9)] dark:shadow-[0_0_0_1px_rgb(0_0_0/0.5),inset_0_1px_0_rgb(255_255_255/0.08)] drawn:outline drawn:outline-1 drawn:outline-(--device-draw-line)" />
            <div
              className="absolute inset-0 rounded-full drawn:[background:conic-gradient(from_calc(var(--a)-3deg),var(--device-draw-ink)_0_6deg,transparent_6deg)]!"
              style={{
                background: "conic-gradient(from calc(var(--a) - 3.5deg), var(--device-key-ink) 0 7deg, transparent 7deg)",
                mask: "radial-gradient(circle closest-side, transparent 30%, #000 31.5%, #000 64%, transparent 65.5%)",
              }}
            />
          </div>
        </div>
      </div>

      {/* The LCD: the spring writes it, so it counts through the detents with the clicks. */}
      <span
        ref={readoutRef}
        aria-hidden
        data-part="lcd"
        className="min-h-[1.6em] min-w-[5.6em] rounded-[0.45em] px-[0.5em] py-[0.3em] text-center text-[0.74em] leading-none tabular-nums text-(--device-lcd-ink) [background:var(--device-lcd)] shadow-(--device-lcd-edge)"
      />
      <span aria-hidden data-part="lettering" className="text-[0.6em] font-semibold uppercase leading-none tracking-[0.16em] text-(--device-label) [text-shadow:var(--device-engrave)]">
        {label}
      </span>
    </div>
  );
}

type Scale = Pick<KnobProps, "min" | "max" | "detents" | "taper">;

/** The detent nearest to a value. */
function indexOf(v: number, { min, max, detents = 24, taper }: Scale) {
  const f = taper === "log" ? Math.log(v / min) / Math.log(max / min) : (v - min) / (max - min);
  return Math.round(Math.min(1, Math.max(0, f)) * detents);
}

/** A detent's value, clean of float dust (2339.9999 → 2340). */
function valueAt(i: number, { min, max, detents = 24, taper }: Scale) {
  const v = taper === "log" ? min * Math.pow(max / min, i / detents) : min + ((max - min) * i) / detents;
  return Math.round(v * 100) / 100;
}

/* --- Demo: a channel strip that recalls scenes on its motors ---------------- */

type Param = "gain" | "tone" | "mix";
type Scene = "A" | "B";

const PARAMS: { key: Param; label: string; min: number; max: number; detents: number; taper?: "log"; bipolar?: boolean; format: (v: number) => string }[] = [
  { key: "gain", label: "Gain", min: -12, max: 12, detents: 24, bipolar: true, format: (v) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(Math.round(v))} dB` },
  { key: "tone", label: "Tone", min: 200, max: 8000, detents: 24, taper: "log", format: (v) => (v >= 1000 ? `${(v / 1000).toFixed(1)} kHz` : `${Math.round(v)} Hz`) },
  { key: "mix", label: "Mix", min: 0, max: 100, detents: 20, format: (v) => `${Math.round(v)} %` },
];

const SCENES: Record<Scene, Record<Param, number>> = {
  A: at({ gain: 18, tone: 16, mix: 8 }),
  B: at({ gain: 9, tone: 8, mix: 15 }),
};
/** Every knob fully down, as the desk powers up. */
const HOME = at({ gain: 0, tone: 0, mix: 0 });
const STAGGER = 120; // ms between one motor starting and the next

/** A scene by detent, so every value sits exactly on a click. */
function at(detents: Record<Param, number>) {
  return Object.fromEntries(PARAMS.map((p) => [p.key, valueAt(detents[p.key], p)])) as Record<Param, number>;
}

const chip = { recall: "Recall", loaded: "Loaded", edited: "Edited" } as const;

export default function Demo() {
  const [values, setValues] = useState(HOME);
  const [scene, setScene] = useState<Scene>("A");
  const [status, setStatus] = useState<keyof typeof chip>("recall");
  const travelling = useRef(new Set<Param>());
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const valuesRef = useRef(values);

  useEffect(() => {
    valuesRef.current = values;
  }, [values]);

  /** The motors bring every knob to the scene, one after another. */
  function recall(next: Scene, delay = 0) {
    timers.current.forEach(clearTimeout);
    const moving = PARAMS.filter((p) => valuesRef.current[p.key] !== SCENES[next][p.key]).map((p) => p.key);
    travelling.current = new Set(moving);
    setScene(next);
    setStatus(moving.length ? "recall" : "loaded");
    timers.current = moving.map((key, i) => setTimeout(() => setValues((v) => ({ ...v, [key]: SCENES[next][key] })), delay + i * STAGGER));
  }

  function settled(key: Param) {
    const set = travelling.current;
    if (!set.delete(key) || set.size) return;
    setStatus("loaded");
    play("select", { gain: 0.5 });
  }

  // On power-up the desk recalls scene A.
  const powerUp = useEffectEvent(() => recall("A"));
  useEffect(() => {
    const id = setTimeout(powerUp, 350);
    const pending = timers;
    return () => {
      clearTimeout(id);
      pending.current.forEach(clearTimeout);
    };
  }, []);

  return (
    <div className="@container w-full max-w-[460px] select-none">
      <div data-part="plate" className="relative isolate animate-enter overflow-hidden rounded-[1.25em] p-[0.95em] text-[clamp(11px,4cqw,14px)] [background:var(--device-body)] shadow-[var(--device-body-edge),0_1px_2px_rgb(0_0_0/0.06),0_16px_32px_-18px_rgb(0_0_0/0.3)] @[26rem]:p-[1.2em]">
        <div aria-hidden className="device-grain pointer-events-none absolute inset-0 -z-10 rounded-[inherit]" />

        <div className="flex items-stretch gap-[0.55em]">
          {/* The scene screen. */}
          <div
            aria-hidden
            data-part="lcd"
            className="flex h-[3.3em] min-w-0 flex-1 flex-col justify-between overflow-hidden rounded-[0.7em] px-[0.8em] pb-[0.5em] pt-[0.55em] text-(--device-lcd-ink) [background:var(--device-lcd)] shadow-(--device-lcd-edge)"
          >
            <span data-part="chip" className="inline-flex items-center gap-[0.35em] self-start rounded-[0.4em] bg-white px-[0.42em] py-[0.24em] text-black">
              <span
                className={cx(
                  "size-[0.55em] rounded-full",
                  // Drawn, the dot keeps red while the motors run (it fires); loaded and edited are ink, as orange is the drawing's own.
                  status === "recall" ? "animate-pulse bg-(--device-rec) drawn:bg-(--device-rec)!" : status === "edited" ? "bg-(--device-hold) drawn:bg-(--device-draw-ink)!" : "bg-black drawn:bg-(--device-draw-ink)!",
                )}
              />
              <span className="text-[0.58em] font-semibold uppercase leading-none tracking-[0.02em]">{chip[status]}</span>
            </span>
            <span className="truncate text-[1.05em] font-light leading-none tracking-[-0.01em]">
              Scene {scene}
              {status === "edited" && <span className="text-(--device-lcd-dim)">*</span>}
            </span>
          </div>

          {/* Scene keys: each recalls its scene on the motors. */}
          {(["A", "B"] as const).map((s) => (
            <button
              key={s}
              type="button"
              data-sound="key"
              aria-label={`Recall scene ${s}`}
              aria-pressed={scene === s && status !== "edited"}
              onPointerDown={(e) => e.button === 0 && focusQuietly(e.currentTarget)}
              onClick={() => recall(s)}
              data-part="key"
              className="group/key w-[2.9em] shrink-0 rounded-[0.7em] outline-offset-2"
            >
              <span className="relative grid h-full place-items-center rounded-[0.7em] text-(--device-key-ink) [background:var(--device-key-face)] shadow-(--device-key-shadow) transition-[transform,box-shadow] duration-(--duration-exit) ease-out group-active/key:translate-y-[2px] group-active/key:shadow-(--device-key-shadow-pressed) group-active/key:duration-75">
                <span
                  aria-hidden
                  data-part="light"
                  className={cx(
                    "absolute inset-x-[30%] top-[0.45em] h-[0.24em] rounded-full transition-[background-color] duration-(--duration-exit)",
                    scene === s ? "bg-(--device-meter-on) drawn:bg-(--device-draw-ink)!" : "bg-(--device-meter-off)",
                  )}
                />
                <span data-part="lettering" className="mt-[0.5em] text-[0.95em] font-medium leading-none [text-shadow:var(--device-engrave)]">{s}</span>
              </span>
            </button>
          ))}
        </div>

        {/* The channel strip. */}
        <div className="mt-[1em] grid grid-cols-3 gap-[0.4em]">
          {PARAMS.map((p) => (
            <Knob
              key={p.key}
              label={p.label}
              value={values[p.key]}
              min={p.min}
              max={p.max}
              detents={p.detents}
              taper={p.taper}
              bipolar={p.bipolar}
              format={p.format}
              onSettle={() => settled(p.key)}
              onChange={(v) => {
                travelling.current.delete(p.key);
                setStatus("edited");
                setValues((prev) => ({ ...prev, [p.key]: v }));
              }}
            />
          ))}
        </div>
      </div>
    </div>
  );
}