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
| Prop | Type | Default | Description |
|---|---|---|---|
value* | number | — | |
onChange* | (value: number) => void | — | |
min | number | 0 | |
max | number | 100 | |
step | number | 1 | |
size | number | 96 | Diameter in px. The value text scales with it. |
strokeWidth | number | 8 | Arc thickness in px. |
valueTemplate | string | '{value}' | How the value is printed and announced. `{value}` is replaced, e.g. "{value}%". |
showValue | boolean | true | Hide the centre text, e.g. when a label beside the knob already shows it. |
disabled | boolean | false | |
readOnly | boolean | false | Focusable and announced, but pointer and keys do not change it. |
label | string | — | Accessible name — a dial has no visible label of its own. |
className | string | — |