Volume Knob
A rotary dial with glowing tick marks; drag up or down, or use the arrow keys.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/volume-knob.json'use client'
import { useRef, useState } from 'react'
import './volume-knob.css'
export interface VolumeKnobProps {
value?: number
defaultValue?: number
onValueChange?: (v: number) => void
label?: string
/** Number of tick marks around the dial. */
ticks?: number
color?: string
fill?: 'dark' | 'light'
className?: string
}
const SWEEP = 270
const START = -135
/** A rotary dial: drag up/down or around it, or use arrow, Page and Home/End keys. role="slider". */
export function VolumeKnob({ value, defaultValue = 64, onValueChange, label = 'Volume', ticks = 28, color = '#a3e635', fill = 'dark', className }: VolumeKnobProps) {
const [inner, setInner] = useState(defaultValue)
const v = value ?? inner
const drag = useRef<{ y: number; v: number } | null>(null)
const set = (n: number) => {
const c = Math.round(Math.min(100, Math.max(0, n)))
if (value === undefined) setInner(c)
onValueChange?.(c)
}
const angle = START + (v / 100) * SWEEP
return (
<div className={['sd-vk', className].filter(Boolean).join(' ')} data-fill={fill} style={{ '--vk-c': color } as React.CSSProperties}>
<div
className="sd-vk__dial"
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={v}
aria-valuetext={`${v}%`}
onKeyDown={(e) => {
const m: Record<string, number> = { ArrowUp: v + 1, ArrowRight: v + 1, ArrowDown: v - 1, ArrowLeft: v - 1, PageUp: v + 10, PageDown: v - 10, Home: 0, End: 100 }
if (e.key in m) (e.preventDefault(), set(m[e.key]))
}}
onPointerDown={(e) => {
drag.current = { y: e.clientY, v }
e.currentTarget.setPointerCapture(e.pointerId)
}}
onPointerMove={(e) => drag.current && set(drag.current.v + (drag.current.y - e.clientY) * 0.6)}
onPointerUp={() => (drag.current = null)}
>
<svg viewBox="0 0 200 200" aria-hidden className="sd-vk__ticks">
{Array.from({ length: ticks }, (_, i) => {
const a = ((START + (i / (ticks - 1)) * SWEEP - 90) * Math.PI) / 180
const on = i / (ticks - 1) <= v / 100
return <line key={i} x1={100 + Math.cos(a) * 84} y1={100 + Math.sin(a) * 84} x2={100 + Math.cos(a) * 94} y2={100 + Math.sin(a) * 94} data-on={on || undefined} />
})}
</svg>
<div className="sd-vk__knob" style={{ transform: `rotate(${angle}deg)` }}>
<span className="sd-vk__dot" />
</div>
<span className="sd-vk__val">{v}</span>
</div>
<span className="sd-vk__label">{label}</span>
</div>
)
}.sd-vk { --fg: #fafafa; --mute: #8e8e8e; --tick: rgb(255 255 255 / 0.14); --knob: #262626; --knob-line: rgb(255 255 255 / 0.1);
display: grid; justify-items: center; gap: 6px; color: var(--fg); font-feature-settings: 'tnum'; }
.sd-vk[data-fill='light'] { --fg: #111; --mute: #6f6f6f; --tick: rgb(0 0 0 / 0.12); --knob: #f4f4f4; --knob-line: rgb(0 0 0 / 0.08); }
.sd-vk__dial { position: relative; width: 168px; height: 168px; display: grid; place-items: center; cursor: ns-resize; touch-action: none; border-radius: 999px; outline: none; }
.sd-vk__dial:focus-visible { box-shadow: 0 0 0 2px var(--fg); }
.sd-vk__ticks { position: absolute; inset: 0; }
.sd-vk__ticks line { stroke: var(--tick); stroke-width: 4; stroke-linecap: round; transition: stroke 200ms; }
.sd-vk__ticks line[data-on] { stroke: var(--vk-c); }
.sd-vk__knob { position: absolute; width: 104px; height: 104px; border-radius: 999px; background: var(--knob); box-shadow: inset 0 0 0 1px var(--knob-line), 0 14px 30px -10px rgb(0 0 0 / 0.7), inset 0 2px 1px rgb(255 255 255 / 0.08);
transition: transform 260ms cubic-bezier(0.2, 1.3, 0.3, 1); }
.sd-vk__dot { position: absolute; left: 50%; top: 10px; width: 8px; height: 8px; margin-left: -4px; border-radius: 99px; background: var(--vk-c); box-shadow: 0 0 10px var(--vk-c); }
.sd-vk__val { position: relative; font-size: 30px; font-weight: 600; letter-spacing: -0.04em; pointer-events: none; }
.sd-vk__label { font-size: 13px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-vk * { transition: none !important; } }Overview
A 270° dial with a ring of ticks that light as the value rises and a big readout in the middle.
Dragging vertically is easier than tracing a circle, so that is how it works; keys step by 1 or 10.
When to use
- Audio, synth and creative-tool controls.
- Any 0–100 setting that benefits from a tactile, compact control.
When not to use
- Forms where a plain range input is expected.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/volume-knob.json$
npx shadcn@latest add https://sundaeui.dev/r/volume-knob.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/volume-knob.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/volume-knob.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy volume-knob.tsx and volume-knob.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { VolumeKnob } from '@/components/sundae/volume-knob'
<VolumeKnob label="Master volume" defaultValue={64} onValueChange={setVolume} />Props
| Prop | Type | Default |
|---|---|---|
value / defaultValue0–100. | number | 64 |
onValueChangeCalled on change. | (v: number) => void | — |
labelAccessible name and caption. | string | 'Volume' |
ticksTick marks around the dial. | number | 28 |
colorLit tick colour. | string | '#a3e635' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- role="slider" with aria-valuenow and a percentage aria-valuetext.
- Visible focus ring around the dial.
- Reduced motion removes the knob easing.
Customization
- Every colour is a CSS custom property on the root class; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ↑ / →
- Up 1
- ↓ / ←
- Down 1
- Page Up / Down
- ±10
- Home / End
- 0 / 100
FAQ
- Is Volume Knob free?
- Yes. Volume Knob is free for personal and commercial projects, with full source on this page.
- How do I install Volume Knob?
- Run npx shadcn@latest add https://sundaeui.dev/r/volume-knob.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Volume Knob support keyboard and screen readers?
- role="slider" with aria-valuenow and a percentage aria-valuetext. Visible focus ring around the dial.
- Does Volume Knob respect reduced motion?
- Reduced motion removes the knob easing.
More like this
All forms →Range Slider
Forms · UnderstatedSnap Slider
Forms · RefinedOTP Field
Forms · RefinedJelly Switch
Forms · Playful- Pro
Drop Zone
Forms · Refined Date Picker
Forms · Understated