03돌려서 조절하는 다이얼
원형 손잡이를 잡아 돌린 각도만큼 값이 오르내리고 게이지 호가 그 각도를 따라 채워지며, 화살표 키와 더하기·빼기 버튼으로는 값이 계단식으로 변합니다.
쓰임볼륨·온도·확대 배율처럼 회전 감각으로 조절하는 값에 회전 다이얼을 붙일 때
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="볼륨" 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="볼륨 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="볼륨 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 돌려서 조절하는 다이얼 — 측정 누적면적 3.827% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 모바일 터치 제스처 9종 — 손가락 하나로 돌아가는 UI
이 데모가 속한 글
모바일 터치 제스처 9종 — 손가락 하나로 돌아가는 UI모바일 터치 제스처는 손가락이 화면에 닿아 움직인 거리와 속도를 읽어 화면을 바꾸는 조작 방식입니다. 아홉 개 중 둘은 손을 떼는 순간에야 결과가 갈리고, 하나는 끝까지 밀지 않으면 아무 일도 일어나지 않습니다. 이 글의 데모 전부 보기


