Draggable Rotation Dial — Free GSAP Inertia Knob Snippet

Draggable Rotation Dial · Forms · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Rotation mode
Pointer orbits become clean angle deltas.
270° sweep
minRotation/maxRotation end stops.
Detented throws
snap quantizes solved inertia endpoints.
End-stop feel
0.9 edgeResistance at the extremes.
Throw-aware readout
onThrowUpdate keeps values live post-release.
Conic fill ring
One CSS variable paints the arc.
Touch-correct
touch-action: none beats scroll hijack.
Wraparound-free
No atan2 seams at ±180°.

About this UI Snippet

Draggable Rotation Dial — A Real Knob From type: "rotation"

Screenshot of the Draggable Rotation Dial snippet rendered live

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.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Instead of reverse-engineering GSAP's rotation pipeline yourself, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how the snap function's Math.round(value / 13.5) * 13.5 formula turns InertiaPlugin's solved end rotation into 5% detents, and why edgeResistance of 0.9 produces a firm end-stop feel rather than a soft one. The same assistant can help optimize it, for instance checking whether writing the --fill custom property every single onDrag and onThrowUpdate call could be throttled without hurting the visual smoothness of the conic-gradient fill ring. It's also useful for extending the dial: ask it to add double-click-to-reset-to-default, support two-finger rotation gestures on touch, or expose the current value to a hidden form input for submission. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a draggable rotary dial control in plain HTML, CSS, and JavaScript using GSAP's Draggable plugin with its rotation type and InertiaPlugin (load both from a CDN alongside core GSAP) — no manual pointer-angle math.

Requirements:
- A circular knob element configured with Draggable's rotation type so dragging anywhere on the knob rotates it based on the pointer's angle around its own center, not the pointer's raw x/y position.
- Constrain the rotation to a 270-degree sweep using minRotation and maxRotation bounds (e.g. -135 to 135 degrees), with edgeResistance set high enough (around 0.9) that dragging past either end barely moves the knob further, mimicking a physical end stop.
- Enable inertia so that releasing the knob mid-flick continues rotating it and glides to a natural stop rather than halting instantly, and provide a snap function that rounds the inertia-solved end rotation to the nearest 5 percent step of the total sweep range, so flicks always settle on a clean detent value.
- Register both an onDrag and an onThrowUpdate callback that call the same rendering function, since the value keeps changing during the momentum glide after the pointer is released, not only while actively dragging.
- That rendering function must map the current rotation to a 0-100 percentage, update a live numeric readout, and set a single CSS custom property that drives a conic-gradient background arc representing the fill amount, without using SVG or manual stroke-dasharray math.
- Set touch-action: none on the draggable knob element so mobile browsers hand rotation gestures to the drag library instead of interpreting them as page scrolling.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Add the GSAP CDNsgsap, Draggable, and InertiaPlugin from the CDN panel.
  2. 2
    Paste HTML, CSS, and JSThe dial renders at 50% with its fill ring.
  3. 3
    Drag around the knobPointer orbit becomes rotation; the readout tracks.
  4. 4
    Flick and releaseInertia glides the dial into a 5% detent.
  5. 5
    Push past an endFirm resistance at ±135°, like real end stops.
  6. 6
    Wire the valuerender() is where you emit to inputs or state.

Real-world uses

Common Use Cases

Volume and media controls
The knob for a music player or volume control.
Smart-home dials
Thermostats and dimmers, beside a brightness slider.
Audio tool UIs
Synth and mixer knobs; the vanilla-math version is rotary knob.
Settings inputs
A tactile alternative to a range slider for bounded values.
Game options
Sensitivity and FOV dials with detent feedback.
Data thresholds
Set alert levels feeding a gauge chart.

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.