Draggable Rotation Dial — Free GSAP Inertia Knob Snippet
Draggable Rotation Dial · Forms · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="dsd-wrap">
<div class="dsd-readout"><b id="dsdValue">50</b><span>%</span></div>
<div class="dsd-shell" id="dsdShell">
<div class="dsd-dial" id="dsdDial">
<div class="dsd-marker"></div>
</div>
</div>
<p class="dsd-hint">Drag to rotate · flick for inertia · snaps to 5% steps</p>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0b0d16;color:#fff;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.dsd-wrap{display:flex;flex-direction:column;align-items:center;gap:22px}
.dsd-readout{display:flex;align-items:baseline;gap:4px;font-variant-numeric:tabular-nums}
.dsd-readout b{font-size:52px;font-weight:800;letter-spacing:-.03em;background:linear-gradient(120deg,#a5b4fc,#22d3ee);-webkit-background-clip:text;background-clip:text;color:transparent}
.dsd-readout span{font-size:20px;color:#8a90a8}
.dsd-shell{position:relative;width:210px;height:210px;border-radius:50%;background:conic-gradient(from 225deg,#818cf8 var(--fill,50%),rgba(255,255,255,.08) var(--fill,50%));display:flex;align-items:center;justify-content:center}
.dsd-shell::before{content:'';position:absolute;inset:10px;border-radius:50%;background:#0b0d16}
.dsd-dial{position:relative;width:158px;height:158px;border-radius:50%;background:radial-gradient(circle at 34% 28%,#232c4e,#131a30);border:1px solid rgba(255,255,255,.14);box-shadow:0 18px 44px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.1);cursor:grab;touch-action:none;will-change:transform}
.dsd-dial:active{cursor:grabbing}
.dsd-marker{position:absolute;top:12px;left:50%;width:6px;height:26px;margin-left:-3px;border-radius:99px;background:#22d3ee;box-shadow:0 0 12px rgba(34,211,238,.8)}
.dsd-hint{color:#5f6782;font-size:12px;letter-spacing:.05em}gsap.registerPlugin(Draggable, InertiaPlugin);
var MIN = -135, MAX = 135; // 270° of travel
var shell = document.getElementById('dsdShell');
var valueEl = document.getElementById('dsdValue');
function render(rotation) {
var pct = Math.round((rotation - MIN) / (MAX - MIN) * 100);
valueEl.textContent = pct;
shell.style.setProperty('--fill', pct + '%');
}
// type:'rotation' converts pointer movement around the element's center
// into rotation degrees; bounds clamp the sweep like a real volume knob.
Draggable.create('#dsdDial', {
type: 'rotation',
bounds: { minRotation: MIN, maxRotation: MAX },
inertia: true,
edgeResistance: 0.9,
// Snap the *thrown* end value to 5% detents (13.5° each).
snap: function (value) {
return Math.round(value / 13.5) * 13.5;
},
onDrag: function () { render(this.rotation); },
onThrowUpdate: function () { render(this.rotation); }
});
// Start at 50%.
gsap.set('#dsdDial', { rotation: 0 });
render(0);Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Draggable Rotation Dial — A Real Knob From type: "rotation"

Rotary knobs are the hardest common control to build by hand: you're converting pointer positions into angles around a center, handling the ±180° wraparound, clamping a sweep range, and — if you want it to feel physical — adding flick momentum with snap detents. GSAP's Draggable has a dedicated mode for all of it: type: 'rotation'. This snippet builds a 270° volume-style dial with inertia, 5% detents, a glowing marker, and a conic-gradient fill ring that doubles as the value display.
type: 'rotation' replaces the atan2 layer
In rotation mode, Draggable computes the angle between the pointer and the element's center each frame and applies the *delta* as rotation — including the wraparound bookkeeping where naive atan2 implementations jump 360° when crossing the negative x-axis. Your code never sees coordinates; it reads this.rotation, a clean accumulated angle, in onDrag.
bounds become a mechanical sweep
bounds: { minRotation: -135, maxRotation: 135 } gives the knob a 270° travel — the classic hi-fi volume sweep with a dead zone at the bottom. With edgeResistance: 0.9, over-rotating past an end barely moves the dial (10% follow-through), reproducing the feel of a knob hitting its end stops, and releases settle back inside the range.
inertia + snap = detents
inertia: true carries flick velocity into a glide, and the snap function quantizes the *solved end value*: InertiaPlugin computes where the throw would naturally stop, passes it to snap, and retargets the glide to Math.round(value / 13.5) × 13.5 — 5% steps across the 270° sweep. Because snapping happens at the destination-solving stage (not as a post-hoc correction), the dial decelerates *into* a detent in one continuous motion, exactly like a weighted encoder wheel.
One render() serves drag and throw
Both onDrag and onThrowUpdate call the same render(this.rotation), which maps the angle to 0–100 and writes two things: the tabular-numeral readout and a --fill custom property. Handling both callbacks matters — a flick keeps changing the value *after* release, and forgetting onThrowUpdate is the classic bug where the readout freezes while the knob keeps spinning.
The fill ring is a conic gradient driven by one variable
The shell's background is conic-gradient(from 225deg, indigo var(--fill), faint var(--fill)) — starting at the sweep's origin (225° = bottom-left) and hard-stopping at the current percentage. Updating one CSS custom property per frame repaints the arc; no SVG, no dash math, and an inset pseudo-element hollows the ring.
touch-action: none, again
As with every pointer-driven control, touch-action: none on the dial keeps mobile browsers from interpreting rotation gestures as scrolling — miss it and the knob works on desktop while silently failing on touch.
Customizing it
Change the detent size (one divisor), widen the sweep, or emit the value to a real input for form submission. Related controls: the vanilla-math rotary knob, linear cousins in range slider and brightness slider, x/y momentum in drag throw notes, and audio context in volume control.
Step by step
How to Use
- 1Add the GSAP CDNsgsap, Draggable, and InertiaPlugin from the CDN panel.
- 2Paste HTML, CSS, and JSThe dial renders at 50% with its fill ring.
- 3Drag around the knobPointer orbit becomes rotation; the readout tracks.
- 4Flick and releaseInertia glides the dial into a 5% detent.
- 5Push past an endFirm resistance at ±135°, like real end stops.
- 6Wire the valuerender() is where you emit to inputs or state.
Real-world uses
Common Use Cases
edgeResistance of 0.9 at the extreme ends.Got questions?
Frequently Asked Questions
The full angle pipeline: computing pointer angle around the element's live center, applying deltas (so grabbing any point on the knob rotates from there, without jumping the marker to the pointer), and the ±180° wraparound where raw atan2 implementations suddenly leap 360°. Handlers just read this.rotation as a clean accumulated angle.
InertiaPlugin solves the throw's natural end rotation first, then passes it through the snap function, which rounds to the nearest 13.5° (5% of the 270° sweep). The glide is retargeted to that detent, so the dial decelerates into its resting notch in one continuous motion — snapping at the destination-solve stage, not as a jarring post-correction.
Because with inertia the value keeps changing after the finger lifts: onDrag stops firing at release, but the dial glides on for several hundred milliseconds. onThrowUpdate fires during that glide. Skipping it is the classic knob bug — the number freezes at the release value while the marker visibly keeps rotating.
The shell's background is a conic-gradient starting from 225° (the sweep's bottom-left origin) with a hard color stop at var(--fill); render() writes that custom property as a percentage each frame, and the browser repaints the arc. An inset pseudo-element covers the middle, leaving a 10px ring. One variable, no dash-offset math.
Yes — remove bounds and it rotates continuously, accumulating beyond 360° (this.rotation keeps counting, which suits jog wheels and infinite scrubbing). For value mapping, take rotation modulo 360 or map the raw accumulation to your range. Keep snap if you still want detents on an endless encoder.
Create the Draggable in a mount effect — useEffect, onMounted, or ngAfterViewInit — from a dial ref, and kill it in the cleanup so pointer listeners release on unmount. Emit values from render() via a callback prop or event rather than binding rotation to state (that would re-render per frame); commit to state only on onDragEnd/onThrowComplete. The ring and readout style directly with Tailwind arbitrary values.
Draggable instances expose rotation as a settable property, not just a read value. Compute the starting angle from your saved value — invert the render() mapping, e.g. rotation = (value / 100) * 270 - 135 for this 270° sweep — and set it on the Draggable instance before the first render() call, then call render(startRotation) once to sync the readout and fill ring. GSAP's set() on the target element also accepts a rotation value directly if you'd rather skip the Draggable instance property.