Knob
React formCircular dial input. Drag to rotate, keyboard accessible, SVG-rendered. Useful for compact numeric controls (volume, brightness, gauges).
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/knob.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/knob.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/knob.json$ bunx shadcn@latest add https://react.uipkge.dev/r/react/knob.json
Or with the named registry:
npx shadcn@latest add @uipkge-react/knob
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
value Controlled value | number | — | optional |
defaultValue Default value when uncontrolled | number | — | optional |
onValueChange Emitted when the value changes | (value: number) => void | — | optional |
onChange Also fired on every committed change (parity with the Vue `change` event) | (value: number) => void | — | optional |
min | number | — | optional |
max | number | — | optional |
step | number | — | optional |
size | number | — | optional |
strokeWidth | number | — | optional |
valueColor | string | — | optional |
rangeColor | string | — | optional |
disabled | boolean | — | optional |
readonly | boolean | — | optional |
showValue | boolean | — | optional |
className | string | — | optional |
renderValue Custom renderer for the centered value text. | (value: number) => React.ReactNode | — | optional |
Files (2)
-
components/ui/knob/Knob.tsx 6.9 kB
'use client' import * as React from 'react' import { cn } from '@/lib/utils' export interface KnobProps { /** Controlled value */ value?: number /** Default value when uncontrolled */ defaultValue?: number /** Emitted when the value changes */ onValueChange?: (value: number) => void /** Also fired on every committed change (parity with the Vue `change` event) */ onChange?: (value: number) => void min?: number max?: number step?: number size?: number strokeWidth?: number valueColor?: string rangeColor?: string disabled?: boolean readonly?: boolean showValue?: boolean className?: string /** Custom renderer for the centered value text. */ renderValue?: (value: number) => React.ReactNode } const startAngle = -Math.PI * 0.75 const endAngle = Math.PI * 0.75 const sweep = endAngle - startAngle function clamp(v: number, lo: number, hi: number) { return Math.min(hi, Math.max(lo, v)) } function arcPath(cx: number, cy: number, r: number, a1: number, a2: number) { const x1 = cx + r * Math.cos(a1) const y1 = cy + r * Math.sin(a1) const x2 = cx + r * Math.cos(a2) const y2 = cy + r * Math.sin(a2) const large = a2 - a1 > Math.PI ? 1 : 0 return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}` } const Knob = React.forwardRef<SVGSVGElement, KnobProps>( ( { value: controlledValue, defaultValue = 0, onValueChange, onChange, min = 0, max = 100, step = 1, size = 100, strokeWidth = 14, valueColor = 'var(--primary)', rangeColor = 'var(--muted)', disabled = false, readonly = false, showValue = true, className, renderValue, }, ref, ) => { const isControlled = controlledValue !== undefined const [internal, setInternal] = React.useState(defaultValue) const rawValue = isControlled ? controlledValue! : internal const svgRef = React.useRef<SVGSVGElement | null>(null) const setRefs = React.useCallback( (node: SVGSVGElement | null) => { svgRef.current = node if (typeof ref === 'function') ref(node) else if (ref) (ref as React.MutableRefObject<SVGSVGElement | null>).current = node }, [ref], ) const [isDragging, setIsDragging] = React.useState(false) const value = clamp(rawValue, min, max) const percent = max === min ? 0 : (value - min) / (max - min) const rangePath = arcPath(50, 50, 40, startAngle, endAngle) const valuePath = arcPath(50, 50, 40, startAngle, startAngle + sweep * percent) function snap(v: number) { const stepped = Math.round((v - min) / step) * step + min return clamp(stepped, min, max) } function setValue(v: number) { if (disabled || readonly) return const next = snap(v) if (next === rawValue) return if (!isControlled) setInternal(next) onValueChange?.(next) onChange?.(next) } function angleFromEvent(e: React.PointerEvent): number { const rect = svgRef.current!.getBoundingClientRect() const cx = rect.left + rect.width / 2 const cy = rect.top + rect.height / 2 return Math.atan2(e.clientY - cy, e.clientX - cx) } function angleToValue(angle: number): number { let a = angle - startAngle if (a < 0) a += Math.PI * 2 if (a > sweep) { return a < (Math.PI * 2 + sweep) / 2 ? max : min } return min + (a / sweep) * (max - min) } function onPointerDown(e: React.PointerEvent) { if (disabled || readonly) return ;(e.target as Element).setPointerCapture(e.pointerId) setIsDragging(true) setValue(angleToValue(angleFromEvent(e))) } function onPointerMove(e: React.PointerEvent) { if (!isDragging) return setValue(angleToValue(angleFromEvent(e))) } function onPointerUp(e: React.PointerEvent) { if (!isDragging) return setIsDragging(false) ;(e.target as Element).releasePointerCapture(e.pointerId) } function onKeyDown(e: React.KeyboardEvent) { if (disabled || readonly) return const big = step * 10 switch (e.key) { case 'ArrowUp': case 'ArrowRight': e.preventDefault() setValue(value + step) break case 'ArrowDown': case 'ArrowLeft': e.preventDefault() setValue(value - step) break case 'PageUp': e.preventDefault() setValue(value + big) break case 'PageDown': e.preventDefault() setValue(value - big) break case 'Home': e.preventDefault() setValue(min) break case 'End': e.preventDefault() setValue(max) break } } function onWheel(e: React.WheelEvent) { if (disabled || readonly) return // Note: React's synthetic wheel listener is passive, so preventDefault is // a no-op here. A native non-passive listener is attached below to stop // the page from scrolling while the knob has focus / is hovered. setValue(value + (e.deltaY < 0 ? step : -step)) } // Attach a non-passive wheel listener so e.preventDefault() actually blocks // page scroll — React's onWheel is passive and can't cancel the scroll. React.useEffect(() => { const node = svgRef.current if (!node) return const handler = (e: WheelEvent) => { if (disabled || readonly) return e.preventDefault() } node.addEventListener('wheel', handler, { passive: false }) return () => node.removeEventListener('wheel', handler) }, [disabled, readonly]) return ( <svg ref={setRefs} data-uipkge="" data-slot="knob" className={cn( 'focus-visible:ring-ring inline-block touch-none rounded-full outline-none select-none focus-visible:ring-2', disabled && 'cursor-not-allowed opacity-50', !disabled && !readonly && 'cursor-pointer', className, )} width={size} height={size} viewBox="0 0 100 100" role="slider" aria-valuemin={min} aria-valuemax={max} aria-valuenow={value} aria-disabled={disabled || undefined} aria-readonly={readonly || undefined} tabIndex={disabled ? -1 : 0} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onKeyDown={onKeyDown} onWheel={onWheel} > <path d={rangePath} stroke={rangeColor} strokeWidth={strokeWidth} fill="none" strokeLinecap="round" /> <path d={valuePath} stroke={valueColor} strokeWidth={strokeWidth} fill="none" strokeLinecap="round" /> {showValue && ( <text x="50" y="55" textAnchor="middle" className="fill-foreground text-[18px] font-medium"> {renderValue ? renderValue(value) : value} </text> )} </svg> ) }, ) Knob.displayName = 'Knob' export { Knob } -
components/ui/knob/index.ts 0 kB
export { Knob, type KnobProps } from './Knob'
Raw manifest: https://react.uipkge.dev/r/react/knob.json