03Drag-to-turn value dial
Grabbing the round knob and turning it raises and lowers the value with the gauge arc filling to match, while arrow keys and the buttons step the value in fixed increments.
Best forVolume, temperature, zoom factors — any value that feels natural to turn rather than type
atan2stroke-dashoffsetArrowUp

<main class="stage">
<div class="dl is-demo" id="dl-root">
<div class="dl__dial" id="dl-dial" role="slider" tabindex="0" aria-label="Volume" aria-valuemin="0" aria-valuemax="100" aria-valuenow="20" aria-orientation="vertical">
<svg class="dl__gauge" viewBox="0 0 100 100" aria-hidden="true">
<circle class="dl__track" cx="50" cy="50" r="42"/>
<circle class="dl__fill" id="dl-fill" cx="50" cy="50" r="42"/>
</svg>
<span class="dl__knob" id="dl-knob" aria-hidden="true">
<span class="dl__dot"></span>
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Z" /></g></svg>
</span>
</div>
<div class="dl__side">
<span class="dl__value" aria-live="polite"><span class="dl__n dl__n--a">20</span><span class="dl__n dl__n--b">60</span><span class="dl__n dl__n--c">95</span><span class="dl__unit">%</span></span>
<div class="dl__btns">
<button class="dl__btn" type="button" id="dl-dec" aria-label="Volume down by 4">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8"/></g></svg>
</button>
<button class="dl__btn" type="button" id="dl-inc" aria-label="Volume up by 4">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/></g></svg>
</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-orange);
.dl {
display: flex;
align-items: center;
gap: $sp-5;
user-select: none;
-webkit-user-select: none;
}
.dl__dial {
position: relative;
width: 128px;
height: 128px;
border-radius: 50%;
cursor: grab;
touch-action: none;
}
.dl__dial:active { cursor: grabbing; }
.dl__dial:focus-visible { outline: 3px solid $subject-cream; outline-offset: 4px; }
.dl__gauge { position: absolute; inset: 0; transform: rotate(135deg); }
.dl__track, .dl__fill {
fill: none;
stroke-width: 9;
stroke-linecap: round;
}
.dl__track { stroke: rgba(255, 247, 230, .45); stroke-dasharray: 197.92 66.06; }
.dl__fill {
stroke: $color;
stroke-dasharray: 197.92 66.06; // 270도만 그린다 — gap 을 안 주면 dash 가 반복돼 호가 꼬인다
stroke-dashoffset: 197.92;
transition: stroke-dashoffset $duration $easing;
}
.dl__knob svg { transition: transform $duration $easing; }
.dl__knob {
position: absolute;
inset: 17px;
display: flex;
align-items: center;
justify-content: center;
background: $subject-cream; // 노브 판은 크림, 게이지 호는 $color(잉크) — 서로 대비
border-radius: 50%;
box-shadow: $shadow-press;
color: $color;
transform: rotate(225deg);
transition: transform $duration $easing;
}
.dl__dot {
position: absolute;
top: $sp-2;
left: 50%;
width: 8px; height: 8px;
margin-left: -4px;
border-radius: 50%;
background: $stage-orange;
}
.dl__side { display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.dl__value {
position: relative;
height: 26px;
min-width: 72px;
text-align: center;
}
.dl__n {
position: absolute;
right: 18px;
font-size: 22px; line-height: 26px; font-weight: 800;
color: $subject-ink;
font-variant-numeric: tabular-nums;
opacity: 0;
transition: opacity $dur-quick steps(1, end);
}
.dl__n.is-on { opacity: 1; }
.dl__n--a.is-on { opacity: 1; }
.dl__unit {
position: absolute;
right: 0;
font-size: 12px; line-height: 30px; font-weight: 700;
color: $subject-ink; // 주황 무대 위 흐린 잉크 2.74:1 → 5.50:1
}
.dl__btns { display: flex; gap: $sp-2; }
.dl__btn {
display: inline-flex; align-items: center; justify-content: center;
width: 34px; height: 34px;
border: 1px solid rgba(255, 247, 230, .6);
background: rgba(255, 247, 230, .12);
border-radius: $r-control;
color: $subject-cream;
cursor: pointer;
transition: background $duration $easing;
}
.dl__btn:hover { background: $subject-cream; color: $stage-orange; }
.dl__btn:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
// ── 자동 루프: 20 → 60 → 95 로 돌고 되돌아온다. 숫자 교체는 steps 컷 ──
.dl.is-demo .dl__knob { animation: dl-turn $dur-loop $easing infinite; }
.dl.is-demo .dl__knob svg { animation: dl-turn-back $dur-loop $easing infinite; }
@keyframes dl-turn-back {
0% { transform: rotate(-225deg); }
30%, 40% { transform: rotate(-387deg); }
66%, 76% { transform: rotate(-481.5deg); }
88%, 100% { transform: rotate(-225deg); }
}
@keyframes dl-turn {
0% { transform: rotate(225deg); }
30%, 40% { transform: rotate(387deg); }
66%, 76% { transform: rotate(481.5deg); }
88%, 100% { transform: rotate(225deg); }
}
.dl.is-demo .dl__fill { animation: dl-fill $dur-loop $easing infinite; }
@keyframes dl-fill {
0% { stroke-dashoffset: 197.92; }
30%, 40% { stroke-dashoffset: 79.17; }
66%, 76% { stroke-dashoffset: 9.9; }
88%, 100% { stroke-dashoffset: 197.92; }
}
.dl.is-demo .dl__n { transition: none; }
.dl.is-demo .dl__n--a { animation: dl-na $dur-loop steps(1, end) infinite; }
.dl.is-demo .dl__n--b { animation: dl-nb $dur-loop steps(1, end) infinite; }
.dl.is-demo .dl__n--c { animation: dl-nc $dur-loop steps(1, end) infinite; }
@keyframes dl-na { 0%, 29% { opacity: 1; } 30%, 100% { opacity: 0; } }
@keyframes dl-nb { 0%, 29% { opacity: 0; } 30%, 65% { opacity: 1; } 66%, 100% { opacity: 0; } }
@keyframes dl-nc { 0%, 65% { opacity: 0; } 66%, 87% { opacity: 1; } 88%, 100% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.dl.is-demo .dl__knob,
.dl.is-demo .dl__fill,
.dl.is-demo .dl__n { animation: none !important; }
.dl.is-demo .dl__n--a { opacity: 1; }
.dl.is-demo .dl__n--b, .dl.is-demo .dl__n--c { opacity: 0; }
.dl__knob, .dl__fill { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dl {
display: flex;
align-items: center;
gap: 20px;
user-select: none;
-webkit-user-select: none;
}
.dl__dial {
position: relative;
width: 128px;
height: 128px;
border-radius: 50%;
cursor: grab;
touch-action: none;
}
.dl__dial:active {
cursor: grabbing;
}
.dl__dial:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 4px;
}
.dl__gauge {
position: absolute;
inset: 0;
transform: rotate(135deg);
}
.dl__track, .dl__fill {
fill: none;
stroke-width: 9;
stroke-linecap: round;
}
.dl__track {
stroke: rgba(255, 247, 230, 0.45);
stroke-dasharray: 197.92 66.06;
}
.dl__fill {
stroke: #17141a;
stroke-dasharray: 197.92 66.06;
stroke-dashoffset: 197.92;
transition: stroke-dashoffset 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dl__knob svg {
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dl__knob {
position: absolute;
inset: 17px;
display: flex;
align-items: center;
justify-content: center;
background: #fff7e6;
border-radius: 50%;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
color: #17141a;
transform: rotate(225deg);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dl__dot {
position: absolute;
top: 8px;
left: 50%;
width: 8px;
height: 8px;
margin-left: -4px;
border-radius: 50%;
background: #ff4d1f;
}
.dl__side {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.dl__value {
position: relative;
height: 26px;
min-width: 72px;
text-align: center;
}
.dl__n {
position: absolute;
right: 18px;
font-size: 22px;
line-height: 26px;
font-weight: 800;
color: #17141a;
font-variant-numeric: tabular-nums;
opacity: 0;
transition: opacity 220ms steps(1, end);
}
.dl__n.is-on {
opacity: 1;
}
.dl__n--a.is-on {
opacity: 1;
}
.dl__unit {
position: absolute;
right: 0;
font-size: 12px;
line-height: 30px;
font-weight: 700;
color: #17141a;
}
.dl__btns {
display: flex;
gap: 8px;
}
.dl__btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: 1px solid rgba(255, 247, 230, 0.6);
background: rgba(255, 247, 230, 0.12);
border-radius: 12px;
color: #fff7e6;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dl__btn:hover {
background: #fff7e6;
color: #ff4d1f;
}
.dl__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.dl.is-demo .dl__knob {
animation: dl-turn 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.dl.is-demo .dl__knob svg {
animation: dl-turn-back 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes dl-turn-back {
0% {
transform: rotate(-225deg);
}
30%, 40% {
transform: rotate(-387deg);
}
66%, 76% {
transform: rotate(-481.5deg);
}
88%, 100% {
transform: rotate(-225deg);
}
}
@keyframes dl-turn {
0% {
transform: rotate(225deg);
}
30%, 40% {
transform: rotate(387deg);
}
66%, 76% {
transform: rotate(481.5deg);
}
88%, 100% {
transform: rotate(225deg);
}
}
.dl.is-demo .dl__fill {
animation: dl-fill 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes dl-fill {
0% {
stroke-dashoffset: 197.92;
}
30%, 40% {
stroke-dashoffset: 79.17;
}
66%, 76% {
stroke-dashoffset: 9.9;
}
88%, 100% {
stroke-dashoffset: 197.92;
}
}
.dl.is-demo .dl__n {
transition: none;
}
.dl.is-demo .dl__n--a {
animation: dl-na 2s steps(1, end) infinite;
}
.dl.is-demo .dl__n--b {
animation: dl-nb 2s steps(1, end) infinite;
}
.dl.is-demo .dl__n--c {
animation: dl-nc 2s steps(1, end) infinite;
}
@keyframes dl-na {
0%, 29% {
opacity: 1;
}
30%, 100% {
opacity: 0;
}
}
@keyframes dl-nb {
0%, 29% {
opacity: 0;
}
30%, 65% {
opacity: 1;
}
66%, 100% {
opacity: 0;
}
}
@keyframes dl-nc {
0%, 65% {
opacity: 0;
}
66%, 87% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.dl.is-demo .dl__knob,
.dl.is-demo .dl__fill,
.dl.is-demo .dl__n {
animation: none !important;
}
.dl.is-demo .dl__n--a {
opacity: 1;
}
.dl.is-demo .dl__n--b, .dl.is-demo .dl__n--c {
opacity: 0;
}
.dl__knob, .dl__fill {
transition: none;
}
}// 실제 동작: 각도(atan2)로 값 조절, 화살표 키·버튼은 4씩 계단식.
var root = document.getElementById('dl-root');
var dial = document.getElementById('dl-dial');
var knob = document.getElementById('dl-knob');
var fill = document.getElementById('dl-fill');
var icon = knob.querySelector('svg');
var nums = dial.parentElement.querySelectorAll('.dl__n');
var SPAN = 270, BASE = 225; // -135deg..+135deg
var value = 20;
var LEN = 2 * Math.PI * 42 * (SPAN / 360);
fill.style.strokeDasharray = LEN + ' ' + (2 * Math.PI * 42 - LEN);
function setValue(v) {
value = Math.max(0, Math.min(100, Math.round(v)));
var deg = BASE + (value / 100) * SPAN;
knob.style.transform = 'rotate(' + deg + 'deg)';
icon.style.transform = 'rotate(' + (-deg) + 'deg)'; // 아이콘은 노브와 반대로 — 늘 바로 선다
fill.style.strokeDashoffset = LEN * (1 - value / 100);
dial.setAttribute('aria-valuenow', value);
var near = value < 40 ? 0 : value < 78 ? 1 : 2;
nums.forEach(function (n, i) { n.classList.toggle('is-on', i === near); });
}
function fromPointer(e) {
var r = dial.getBoundingClientRect();
var dx = e.clientX - (r.left + r.width / 2);
var dy = e.clientY - (r.top + r.height / 2);
var deg = Math.atan2(dy, dx) * 180 / Math.PI; // -180..180 (0 = 오른쪽)
var rel = deg - BASE; // -135 기준 상대각
while (rel > 180) rel -= 360;
while (rel < -180) rel += 360;
if (rel < -SPAN / 2) rel = -SPAN / 2;
if (rel > SPAN / 2) rel = SPAN / 2;
setValue((rel + SPAN / 2) / SPAN * 100);
}
var dragging = false;
dial.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = true;
dial.setPointerCapture(e.pointerId);
fromPointer(e);
e.preventDefault();
});
dial.addEventListener('pointermove', function (e) { if (dragging) fromPointer(e); });
function drop() { dragging = false; }
dial.addEventListener('pointerup', drop);
dial.addEventListener('pointercancel', drop);
dial.addEventListener('keydown', function (e) {
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { root.classList.remove('is-demo'); setValue(value + 4); e.preventDefault(); }
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { root.classList.remove('is-demo'); setValue(value - 4); e.preventDefault(); }
else if (e.key === 'Home') { root.classList.remove('is-demo'); setValue(0); e.preventDefault(); }
else if (e.key === 'End') { root.classList.remove('is-demo'); setValue(100); e.preventDefault(); }
});
document.getElementById('dl-inc').addEventListener('click', function () { root.classList.remove('is-demo'); setValue(value + 4); });
document.getElementById('dl-dec').addEventListener('click', function () { root.classList.remove('is-demo'); setValue(value - 4); });
setValue(20);import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DragDial.module.scss";
export type DragDialProps = {
initial?: number;
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const SPAN = 270, BASE = 225;
const LEN = 2 * Math.PI * 42 * (SPAN / 360);
const IconSpeaker = () => (
<svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Z"/></g></svg>
);
const IconPlus = () => (
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/></g></svg>
);
const IconMinus = () => (
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8"/></g></svg>
);
export default function DragDial({
initial = 20,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: DragDialProps) {
const [value, setValue] = useState(initial);
const dialRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false) as Box<boolean>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function clamp(v: number) { return Math.max(0, Math.min(100, Math.round(v))); }
function fromPointer(e: PEvt) {
const el = dialRef.current as HTMLDivElement;
const r = el.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
let rel = Math.atan2(dy, dx) * 180 / Math.PI - BASE;
while (rel > 180) rel -= 360;
while (rel < -180) rel += 360;
rel = Math.max(-SPAN / 2, Math.min(SPAN / 2, rel));
setValue(((rel + SPAN / 2) / SPAN) * 100);
}
function onDown(e: PEvt) {
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
fromPointer(e);
e.preventDefault();
}
function onMove(e: PEvt) { if (dragging.current) fromPointer(e); }
function onUp() { dragging.current = false; }
function onKey(e: KeyEvt) {
if (e.key === "ArrowUp" || e.key === "ArrowRight") { setValue(clamp(value + 4)); e.preventDefault(); }
else if (e.key === "ArrowDown" || e.key === "ArrowLeft") { setValue(clamp(value - 4)); e.preventDefault(); }
else if (e.key === "Home") { setValue(0); e.preventDefault(); }
else if (e.key === "End") { setValue(100); e.preventDefault(); }
}
const deg = BASE + (value / 100) * SPAN;
return (
<div className={styles.dl} style={vars}>
<div
ref={dialRef}
className={styles.dial}
role="slider"
tabIndex={0}
aria-label="Volume"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(value)}
aria-orientation="vertical"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
>
<svg className={styles.gauge} viewBox="0 0 100 100" aria-hidden="true">
<circle className={styles.track} cx="50" cy="50" r="42" />
<circle
className={styles.fill}
cx="50" cy="50" r="42"
style={{ strokeDasharray: LEN, strokeDashoffset: LEN * (1 - value / 100) }}
/>
</svg>
<span className={styles.knob} style={{ transform: `rotate(${deg}deg)` }} aria-hidden="true">
<span className={styles.dot} />
<IconSpeaker />
</span>
</div>
<div className={styles.side}>
<span className={styles.value} aria-live="polite">{Math.round(value)}%</span>
<div className={styles.btns}>
<button type="button" className={styles.btn} aria-label="Volume down by 4" onClick={(): void => setValue(clamp(value - 4))}>
<IconMinus />
</button>
<button type="button" className={styles.btn} aria-label="Volume up by 4" onClick={(): void => setValue(clamp(value + 4))}>
<IconPlus />
</button>
</div>
</div>
</div>
);
}03 Drag-to-turn value dial — Measured area 3.827% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · 9 Mobile Touch Gestures That Follow Your Finger
The post this demo lives in
9 Mobile Touch Gestures That Follow Your FingerMobile touch gestures turn the distance and speed of a finger on glass into a change on screen. Nine of them are below, See all demos in this post
If you like this effect - similar demos
Interactions08 Scroll-progress cursor ring9 Mouse Effects CSS Tricks You Can Copy-Paste
Interactions03 Six-faced box you drag to spin3D Hover Effect CSS: 9 Pointer-Following Scenes
Interactions03 Keyboard tabs — a tab list recomputing its underline position from the active valueMicro Interactions: 9 Essential UI Patterns