Knob
Vue formCircular dial input. Drag to rotate, keyboard accessible, SVG-rendered. Useful for compact numeric controls (volume, brightness, gauges).
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/knob.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/knob.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/knob.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/knob.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/knob
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
modelValue | number | 0 | optional |
min | number | 0 | optional |
max | number | 100 | optional |
step | number | 1 | optional |
size | number | 100 | optional |
strokeWidth | number | 14 | optional |
valueColor | string | 'var(--primary)' | optional |
rangeColor | string | 'var(--muted)' | optional |
disabled | boolean | false | optional |
readonly | boolean | false | optional |
showValue | boolean | true | optional |
class | HTMLAttributes['class'] | — | optional |
Files (2)
-
app/components/ui/knob/Knob.vue 5.2 kB
<script setup lang="ts"> import { computed, ref } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' const props = withDefaults( defineProps<{ modelValue?: number min?: number max?: number step?: number size?: number strokeWidth?: number valueColor?: string rangeColor?: string disabled?: boolean readonly?: boolean showValue?: boolean class?: HTMLAttributes['class'] }>(), { modelValue: 0, min: 0, max: 100, step: 1, size: 100, strokeWidth: 14, valueColor: 'var(--primary)', rangeColor: 'var(--muted)', disabled: false, readonly: false, showValue: true, }, ) const emits = defineEmits<{ (e: 'update:modelValue', value: number): void (e: 'change', value: number): void }>() const svg = ref<SVGSVGElement | null>(null) const isDragging = ref(false) const value = computed(() => clamp(props.modelValue, props.min, props.max)) const percent = computed(() => { if (props.max === props.min) return 0 return (value.value - props.min) / (props.max - props.min) }) const startAngle = -Math.PI * 0.75 const endAngle = Math.PI * 0.75 const sweep = endAngle - startAngle const rangePath = computed(() => arcPath(50, 50, 40, startAngle, endAngle)) const valuePath = computed(() => arcPath(50, 50, 40, startAngle, startAngle + sweep * percent.value)) 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}` } function clamp(v: number, lo: number, hi: number) { return Math.min(hi, Math.max(lo, v)) } function snap(v: number) { const stepped = Math.round((v - props.min) / props.step) * props.step + props.min return clamp(stepped, props.min, props.max) } function setValue(v: number) { if (props.disabled || props.readonly) return const next = snap(v) if (next === props.modelValue) return emits('update:modelValue', next) emits('change', next) } function angleFromEvent(e: PointerEvent): number { const rect = svg.value!.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 ? props.max : props.min } return props.min + (a / sweep) * (props.max - props.min) } function onPointerDown(e: PointerEvent) { if (props.disabled || props.readonly) return ;(e.target as Element).setPointerCapture(e.pointerId) isDragging.value = true setValue(angleToValue(angleFromEvent(e))) } function onPointerMove(e: PointerEvent) { if (!isDragging.value) return setValue(angleToValue(angleFromEvent(e))) } function onPointerUp(e: PointerEvent) { if (!isDragging.value) return isDragging.value = false ;(e.target as Element).releasePointerCapture(e.pointerId) } function onKeyDown(e: KeyboardEvent) { if (props.disabled || props.readonly) return const big = props.step * 10 switch (e.key) { case 'ArrowUp': case 'ArrowRight': e.preventDefault() setValue(value.value + props.step) break case 'ArrowDown': case 'ArrowLeft': e.preventDefault() setValue(value.value - props.step) break case 'PageUp': e.preventDefault() setValue(value.value + big) break case 'PageDown': e.preventDefault() setValue(value.value - big) break case 'Home': e.preventDefault() setValue(props.min) break case 'End': e.preventDefault() setValue(props.max) break } } function onWheel(e: WheelEvent) { if (props.disabled || props.readonly) return e.preventDefault() setValue(value.value + (e.deltaY < 0 ? props.step : -props.step)) } </script> <template> <svg ref="svg" data-uipkge data-slot="knob" :class=" 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', props.class, ) " :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" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" @pointercancel="onPointerUp" @keydown="onKeyDown" @wheel="onWheel" > <path :d="rangePath" :stroke="rangeColor" :stroke-width="strokeWidth" fill="none" stroke-linecap="round" /> <path :d="valuePath" :stroke="valueColor" :stroke-width="strokeWidth" fill="none" stroke-linecap="round" /> <text v-if="showValue" x="50" y="55" text-anchor="middle" class="fill-foreground text-[18px] font-medium"> <slot name="value" :value="value">{{ value }}</slot> </text> </svg> </template> -
app/components/ui/knob/index.ts 0 kB
export { default as Knob } from './Knob.vue'
Raw manifest: https://uipkge.dev/r/vue/knob.json