v1.0

Knob

Preview

Basic

Loading…

Preview

Code

ts
import Knob from '@/components/form/Knob';

src/components/form/Knob.tsx

AI prompt

text
Build a circular dial (knob) input component in React + TypeScript + Tailwind CSS, drawn in SVG.

## Look
- A square (`size`, default 96px) `rounded-full` element holding an SVG. The dial sweeps 270° clockwise from −135° (bottom-left) to +135° (bottom-right), leaving a 90° gap at the bottom.
- Track: an arc over the full sweep, `stroke-slate-200 dark:stroke-slate-700`, round caps, `strokeWidth` default 8, radius = size/2 − strokeWidth/2 − 1.
- Value arc: from the start to the value's angle, `stroke-indigo-600 dark:stroke-indigo-400`, round caps. Draw nothing at the minimum — a zero-length round-capped arc would paint a dot that reads as "a little".
- Centre text: the value through a template (e.g. "{value}%"), `font-semibold tabular-nums fill-slate-700 dark:fill-slate-200`, font size max(10, size × 0.2), centred both ways.
- Focus ring `ring-2 ring-indigo-400` on the round element. Disabled: 50% opacity, not-allowed cursor. Read-only: default cursor.

## Behaviour
- ANGLE-based dragging: the value follows where the pointer is around the centre (atan2, clockwise from 12 o'clock), so a click lands where it's aimed. Pointer capture, `touch-none select-none`, focus on press.
- In the dead zone at the bottom on a fresh press, take the end on the pressed side. Mid-drag, a jump of more than half the dial means the pointer went round through the gap — hold the end already reached rather than flipping max ↔ min, like a physical knob stop. Commit the release point on pointerup.
- Snap to `min + n * step`, clamp, round to the step's decimals; only call `onChange` on a change.
- Keys: ↑/→ +step, ↓/← −step, PageUp/PageDown ± one tenth of the span (rounded to the step, at least one step), Home/End to min/max.
- `readOnly`: focusable and announced, but pointer and keys don't change it. `disabled`: not focusable.

## API
`value`, `onChange`, `min = 0`, `max = 100`, `step = 1`, `size = 96`, `strokeWidth = 8`, `valueTemplate = '{value}'`, `showValue = true`, `disabled`, `readOnly`, `label` (accessible name — a dial has no visible label), `className`.

## Accessibility
- The round element is `role="slider"` with `aria-valuemin/max/now`, `aria-valuetext` = the templated text, `aria-disabled`, `aria-readonly`; the SVG is `aria-hidden`.

## Demo
A row: "Mix" 42 as "{value}%"; "Gain" −12…12 at 64px / stroke 6 as "{value} dB" starting at −6; "Ratio" 0–1 step 0.1 at 72px; a read-only "Usage" 65%; a disabled one.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useRef } from 'react';
import { cn } from '@/lib/cn';

/** The dial sweeps 270°, from -135° (bottom-left) to +135° (bottom-right), measured clockwise from 12 o'clock. */
const START = -135;
const SWEEP = 270;

function point(c: number, r: number, deg: number) {
  const rad = (deg * Math.PI) / 180;
  return `${c + r * Math.sin(rad)} ${c - r * Math.cos(rad)}`;
}

/** An SVG arc from START to `deg`, clockwise. */
function arc(c: number, r: number, deg: number) {
  const large = deg - START > 180 ? 1 : 0;
  return `M ${point(c, r, START)} A ${r} ${r} 0 ${large} 1 ${point(c, r, deg)}`;
}

function decimals(step: number) {
  const s = String(step);
  return s.includes('.') ? s.length - s.indexOf('.') - 1 : 0;
}

export interface KnobProps {
  value: number;
  onChange: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  /** Diameter in px. The value text scales with it. */
  size?: number;
  /** Arc thickness in px. */
  strokeWidth?: number;
  /** How the value is printed and announced. `{value}` is replaced, e.g. "{value}%". */
  valueTemplate?: string;
  /** Hide the centre text, e.g. when a label beside the knob already shows it. */
  showValue?: boolean;
  disabled?: boolean;
  /** Focusable and announced, but pointer and keys do not change it. */
  readOnly?: boolean;
  /** Accessible name — a dial has no visible label of its own. */
  label?: string;
  className?: string;
}

/**
 * A circular dial: the arc fills clockwise as the value rises.
 *
 * Dragging is ANGLE-based — the value follows where the pointer is around the
 * centre, not how far it has moved vertically. That makes a click land where it
 * is aimed. The cost is the 90° gap at the bottom, where no value lives: a
 * pointer dragged round through it would otherwise snap from max straight to
 * min, so a drag stops at the end it reached, like the real thing.
 */
export default function Knob({
  value,
  onChange,
  min = 0,
  max = 100,
  step = 1,
  size = 96,
  strokeWidth = 8,
  valueTemplate = '{value}',
  showValue = true,
  disabled = false,
  readOnly = false,
  label,
  className,
}: KnobProps) {
  const ref = useRef<HTMLDivElement>(null);
  const dragging = useRef(false);

  const span = max - min || 1;
  const clamped = Math.min(max, Math.max(min, value));
  const fraction = (clamped - min) / span;
  const inert = disabled || readOnly;
  const text = valueTemplate.replace('{value}', String(clamped));

  const commit = (raw: number) => {
    const snapped = min + Math.round((Math.min(max, Math.max(min, raw)) - min) / step) * step;
    const next = Number(Math.min(max, snapped).toFixed(decimals(step)));
    if (next !== value) onChange(next);
  };

  const fromPointer = (e: React.PointerEvent) => {
    const rect = ref.current!.getBoundingClientRect();
    const dx = e.clientX - (rect.left + rect.width / 2);
    const dy = e.clientY - (rect.top + rect.height / 2);
    // Clockwise from 12 o'clock, in -180..180.
    const deg = (Math.atan2(dx, -dy) * 180) / Math.PI;
    let f = (deg - START) / SWEEP;
    // In the dead zone on a fresh press: take the end on the pressed side.
    if (f < 0 || f > 1) f = deg > 0 ? 1 : 0;
    // Mid-drag, a jump of more than half the dial can only mean the pointer
    // went round through the gap — hold the end the value was already at
    // instead of flipping from max to min, as a physical knob would stop.
    if (dragging.current && Math.abs(f - fraction) > 0.5) f = fraction >= 0.5 ? 1 : 0;
    return min + f * span;
  };

  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (inert || e.button !== 0) return;
    e.preventDefault();
    ref.current?.focus();
    e.currentTarget.setPointerCapture(e.pointerId);
    commit(fromPointer(e));
    dragging.current = true;
  };

  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (dragging.current) commit(fromPointer(e));
  };

  const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!dragging.current) return;
    // The last coalesced pointermove can be dropped when the release lands in
    // the same frame; commit the release point so a flick does not stop short.
    if (e.type === 'pointerup') commit(fromPointer(e));
    dragging.current = false;
    if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (inert) return;
    const big = Math.max(step, Math.round(span / 10 / step) * step);
    const next: Record<string, number> = {
      ArrowUp: clamped + step,
      ArrowRight: clamped + step,
      ArrowDown: clamped - step,
      ArrowLeft: clamped - step,
      PageUp: clamped + big,
      PageDown: clamped - big,
      Home: min,
      End: max,
    };
    if (!(e.key in next)) return;
    e.preventDefault();
    commit(next[e.key]);
  };

  const c = size / 2;
  const r = c - strokeWidth / 2 - 1;

  return (
    <div
      ref={ref}
      role="slider"
      tabIndex={disabled ? -1 : 0}
      aria-label={label}
      aria-valuemin={min}
      aria-valuemax={max}
      aria-valuenow={clamped}
      aria-valuetext={text}
      aria-disabled={disabled || undefined}
      aria-readonly={readOnly || undefined}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={endDrag}
      onPointerCancel={endDrag}
      onKeyDown={onKeyDown}
      className={cn(
        'relative inline-block shrink-0 rounded-full touch-none select-none',
        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400',
        disabled ? 'opacity-50 cursor-not-allowed' : readOnly ? 'cursor-default' : 'cursor-pointer',
        className,
      )}
      style={{ width: size, height: size }}
    >
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden>
        <path
          d={arc(c, r, START + SWEEP)}
          fill="none"
          strokeWidth={strokeWidth}
          strokeLinecap="round"
          className="stroke-slate-200 dark:stroke-slate-700"
        />
        {/* A zero-length arc still paints a round-capped dot, which reads as "a little" rather than "none". */}
        {fraction > 0 && (
          <path
            d={arc(c, r, START + fraction * SWEEP)}
            fill="none"
            strokeWidth={strokeWidth}
            strokeLinecap="round"
            className="stroke-indigo-600 dark:stroke-indigo-400"
          />
        )}
        {showValue && (
          <text
            x={c}
            y={c}
            textAnchor="middle"
            dominantBaseline="central"
            fontSize={Math.max(10, size * 0.2)}
            className="fill-slate-700 dark:fill-slate-200 font-semibold tabular-nums"
          >
            {text}
          </text>
        )}
      </svg>
    </div>
  );
}

Props

PropTypeDefaultDescription
value*number—
onChange*(value: number) => void—
minnumber0
maxnumber100
stepnumber1
sizenumber96Diameter in px. The value text scales with it.
strokeWidthnumber8Arc thickness in px.
valueTemplatestring'{value}'How the value is printed and announced. `{value}` is replaced, e.g. "{value}%".
showValuebooleantrueHide the centre text, e.g. when a label beside the knob already shows it.
disabledbooleanfalse
readOnlybooleanfalseFocusable and announced, but pointer and keys do not change it.
labelstring—Accessible name — a dial has no visible label of its own.
classNamestring—