05スナップ選択ダイヤル — 近い目盛りへ弾けるように揃う方式
手を離した位置と最も近い目盛りを比べ、その目盛りへ短く弾みながら自動で揃います。
使いどころタイマーの時間設定、段階式の強度セレクター
nearestNotchSNAP_THRESHOLD_DEGNOTCH_DEG

<main class="stage">
<div class="ss is-demo" id="ss">
<div class="ss__ring" id="ssRing">
<div class="ss__ticks" id="ssTicks"></div>
<button class="ss__handle" id="ssHandle" type="button" aria-label="タイマー時間ダイヤル"></button>
</div>
<p class="ss__value" id="ssValue">15分</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스냅 선택 다이얼 — 계산 겹은 index.html 의 nearestNotch(최단 각거리) 와 easeOutBack 정착 곡선에 있다.
// 여기는 눈금·손잡이만 그린다. 손잡이 각도는 --angle 하나로 전부 구동한다.
$duration: $dur-instant;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-ink);
.ss { display: flex; flex-direction: column; align-items: center; gap: $sp-4; }
.ss__ring {
position: relative;
width: 176px;
height: 176px;
border-radius: 50%;
border: 2px solid rgba($subject-cream, .18);
}
.ss__ticks { position: absolute; inset: 0; }
.ss__tick {
position: absolute;
left: calc(50% - 2px);
top: 4px;
width: 4px;
height: 12px;
border-radius: $r-pill;
background: $subject-cream;
opacity: .5;
transform-origin: 2px 84px;
transform: rotate(calc(var(--i, 0) * 45deg));
}
.ss__handle {
position: absolute;
inset: 26px;
appearance: none;
border: none;
border-radius: 50%;
cursor: grab;
background: $subject-blue-light;
box-shadow: $shadow-raised;
transform: rotate(calc(var(--angle, 0) * 1deg));
touch-action: none;
}
.ss__handle:active { cursor: grabbing; }
.ss__handle:focus-visible { outline: 2px solid $subject-cream; outline-offset: 3px; }
.ss__handle::after {
content: "";
position: absolute;
top: 8px;
left: calc(50% - 3px);
width: 6px;
height: 18px;
border-radius: $r-pill;
background: $subject-ink;
}
.ss__value {
margin: 0;
color: $subject-cream;
font-weight: 800;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.ss__ring { width: 130px; height: 130px; }
.ss__handle { inset: 18px; }
.ss__tick { transform-origin: 2px 61px; }
}
@media (prefers-reduced-motion: reduce) {
.ss__handle { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ss {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.ss__ring {
position: relative;
width: 176px;
height: 176px;
border-radius: 50%;
border: 2px solid rgba(255, 247, 230, 0.18);
}
.ss__ticks {
position: absolute;
inset: 0;
}
.ss__tick {
position: absolute;
left: calc(50% - 2px);
top: 4px;
width: 4px;
height: 12px;
border-radius: 999px;
background: #fff7e6;
opacity: 0.5;
transform-origin: 2px 84px;
transform: rotate(calc(var(--i, 0) * 45deg));
}
.ss__handle {
position: absolute;
inset: 26px;
appearance: none;
border: none;
border-radius: 50%;
cursor: grab;
background: #5b8cf8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: rotate(calc(var(--angle, 0) * 1deg));
touch-action: none;
}
.ss__handle:active {
cursor: grabbing;
}
.ss__handle:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 3px;
}
.ss__handle::after {
content: "";
position: absolute;
top: 8px;
left: calc(50% - 3px);
width: 6px;
height: 18px;
border-radius: 999px;
background: #17141a;
}
.ss__value {
margin: 0;
color: #fff7e6;
font-weight: 800;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.ss__ring {
width: 130px;
height: 130px;
}
.ss__handle {
inset: 18px;
}
.ss__tick {
transform-origin: 2px 61px;
}
}
@media (prefers-reduced-motion: reduce) {
.ss__handle {
transition: none;
}
}(function () {
var NOTCHES = [0, 15, 30, 45, 60, 75, 90, 105]; // 分 단위 눈금 값
var N = NOTCHES.length;
var NOTCH_DEG = 360 / N;
var SNAP_THRESHOLD_DEG = 20; // 놓은 각도가 눈금에서 이 안이면 스냅한다
var LOOP_MS = 2000;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('ss');
var ring = document.getElementById('ssRing');
var ticks = document.getElementById('ssTicks');
var handle = document.getElementById('ssHandle');
var valueEl = document.getElementById('ssValue');
var isDemo = true;
var angle = 0;
var dragging = false;
var settling = false;
var settleFrom = 0, settleTo = 0, settleStart = 0;
var SETTLE_MS = reduce ? 0 : 260;
for (var i = 0; i < N; i++) {
var t = document.createElement('span');
t.className = 'ss__tick';
t.style.setProperty('--i', i);
t.setAttribute('aria-hidden', 'true');
ticks.appendChild(t);
}
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
function angleAt(clientX, clientY) {
var r = ring.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
return Math.atan2(clientY - cy, clientX - cx) * 180 / Math.PI + 90;
}
function norm360(d) { return ((d % 360) + 360) % 360; }
// 계산 겹: 놓은 각도와 가장 가까운 눈금(N 등분) 사이의 최단 각거리를 구해, 문턱 안이면 그 눈금으로 스냅한다.
function nearestNotch(deg) {
var idx = Math.round(deg / NOTCH_DEG) % N;
var notchDeg = idx * NOTCH_DEG;
var dist = Math.abs(((deg - notchDeg + 540) % 360) - 180);
return { idx: idx, notchDeg: notchDeg, dist: dist };
}
function apply(a) {
ring.style.setProperty('--angle', a);
}
function startSettle(target) {
settling = true;
settleFrom = angle;
settleTo = target;
settleStart = performance.now();
}
function easeOvershoot(t) {
// 목표를 살짝 넘겼다 되돌아오는 튕김(easeOutBack) — cubic-bezier 로는 못 만드는 부호 반전 곡선
var c1 = 1.70158, c3 = c1 + 1;
var t1 = t - 1;
return 1 + c3 * t1 * t1 * t1 + c1 * t1 * t1;
}
function tickSettle(nowTs) {
if (!settling) return;
if (SETTLE_MS === 0) { angle = settleTo; settling = false; valueEl.textContent = readout(); return; }
var t = Math.min(1, (nowTs - settleStart) / SETTLE_MS);
angle = settleFrom + (settleTo - settleFrom) * easeOvershoot(t);
if (t >= 1) { settling = false; angle = settleTo; }
}
function readout() {
var idx = Math.round(norm360(angle) / NOTCH_DEG) % N;
return NOTCHES[idx] + '分';
}
handle.addEventListener('pointerdown', function (e) {
human();
dragging = true; settling = false;
handle.setPointerCapture(e.pointerId);
angle = angleAt(e.clientX, e.clientY);
apply(angle);
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
angle = angleAt(e.clientX, e.clientY);
apply(angle);
valueEl.textContent = readout();
});
handle.addEventListener('pointerup', function () {
dragging = false;
var n = nearestNotch(norm360(angle));
if (n.dist <= SNAP_THRESHOLD_DEG) { startSettle(angle + (n.notchDeg - norm360(angle))); }
valueEl.textContent = NOTCHES[n.idx] + '分';
});
// 자동 루프: 드래그 → 놓기 → 스냅 정착을 반복해 계산 흐름 전체를 보여준다
function loop(ts) {
if (isDemo) {
if (!reduce) {
var phase = (ts % LOOP_MS) / LOOP_MS;
if (phase < 0.45) {
angle = phase / 0.45 * 250; // 자유 회전 구간
apply(angle);
valueEl.textContent = readout();
} else if (phase < 0.5) {
if (!settling) {
var n = nearestNotch(norm360(angle));
startSettle(angle + (n.notchDeg - norm360(angle)));
}
} else {
tickSettle(ts);
apply(angle);
valueEl.textContent = readout();
}
} else {
apply(0);
}
} else {
tickSettle(ts);
apply(angle);
}
requestAnimationFrame(loop);
}
apply(0);
requestAnimationFrame(loop);
})();// SnapSelectDial — vanilla 판(run/374/demo/05-arc-snap-select/index.html) 의 React 포트.
// 계산 겹: nearestNotch 가 놓은 각도와 가장 가까운 눈금(N 등분) 사이의 최단 각거리를 구해
// SNAP_THRESHOLD_DEG 안이면 그 눈금으로 스냅하고, easeOutBack 정착 곡선으로 튕기며 멈춘다.
import { useEffect, useRef, useState } from "react";
import styles from "./SnapSelectDial.module.scss";
type Box<T> = { current: T };
const NOTCHES = [0, 15, 30, 45, 60, 75, 90, 105];
const N = NOTCHES.length;
const NOTCH_DEG = 360 / N;
const SNAP_THRESHOLD_DEG = 20;
const LOOP_MS = 2000;
export default function SnapSelectDial() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const ringRef = useRef(null) as unknown as Box<HTMLDivElement>;
const handleRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
const [value, setValue] = useState(NOTCHES[1] + "분");
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const ring = ringRef.current;
const handle = handleRef.current;
if (!root || !ring || !handle) return;
let isDemo = true;
let angle = 0;
let dragging = false;
let settling = false;
let settleFrom = 0;
let settleTo = 0;
let settleStart = 0;
const SETTLE_MS = reduce ? 0 : 260;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function angleAt(clientX: number, clientY: number) {
const r = ring!.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
return (Math.atan2(clientY - cy, clientX - cx) * 180) / Math.PI + 90;
}
function norm360(d: number) { return ((d % 360) + 360) % 360; }
function nearestNotch(deg: number) {
const idx = Math.round(deg / NOTCH_DEG) % N;
const notchDeg = idx * NOTCH_DEG;
const dist = Math.abs(((deg - notchDeg + 540) % 360) - 180);
return { idx, notchDeg, dist };
}
function apply(a: number) { ring!.style.setProperty("--angle", String(a)); }
function startSettle(target: number) {
settling = true;
settleFrom = angle;
settleTo = target;
settleStart = performance.now();
}
function easeOvershoot(t: number) {
const c1 = 1.70158;
const c3 = c1 + 1;
const t1 = t - 1;
return 1 + c3 * t1 * t1 * t1 + c1 * t1 * t1;
}
function readout() {
const idx = Math.round(norm360(angle) / NOTCH_DEG) % N;
return NOTCHES[idx] + "분";
}
function tickSettle(nowTs: number) {
if (!settling) return;
if (SETTLE_MS === 0) { angle = settleTo; settling = false; setValue(readout()); return; }
const t = Math.min(1, (nowTs - settleStart) / SETTLE_MS);
angle = settleFrom + (settleTo - settleFrom) * easeOvershoot(t);
if (t >= 1) { settling = false; angle = settleTo; }
}
function onDown(e: PointerEvent) {
human();
dragging = true; settling = false;
handle!.setPointerCapture(e.pointerId);
angle = angleAt(e.clientX, e.clientY);
apply(angle);
}
function onMove(e: PointerEvent) {
if (!dragging) return;
angle = angleAt(e.clientX, e.clientY);
apply(angle);
setValue(readout());
}
function onUp() {
dragging = false;
const n = nearestNotch(norm360(angle));
if (n.dist <= SNAP_THRESHOLD_DEG) { startSettle(angle + (n.notchDeg - norm360(angle))); }
setValue(NOTCHES[n.idx] + "분");
}
handle.addEventListener("pointerdown", onDown);
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
function loop(ts: number) {
if (isDemo) {
if (!reduce) {
const phase = (ts % LOOP_MS) / LOOP_MS;
if (phase < 0.45) {
angle = (phase / 0.45) * 250;
apply(angle);
setValue(readout());
} else if (phase < 0.5) {
if (!settling) {
const n = nearestNotch(norm360(angle));
startSettle(angle + (n.notchDeg - norm360(angle)));
}
} else {
tickSettle(ts);
apply(angle);
setValue(readout());
}
} else {
apply(0);
}
} else {
tickSettle(ts);
apply(angle);
}
rafRef.current = requestAnimationFrame(loop);
}
apply(0);
rafRef.current = requestAnimationFrame(loop);
return () => {
handle.removeEventListener("pointerdown", onDown);
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", onUp);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.ss + " " + styles.isDemo} ref={rootRef}>
<div className={styles.ring} ref={ringRef}>
<div className={styles.ticks}>
{NOTCHES.map(function (n, i) {
return <span key={n} className={styles.tick} style={{ ["--i" as string]: i }} aria-hidden="true" />;
})}
</div>
<button className={styles.handle} ref={handleRef} type="button" aria-label="타이머 시간 다이얼" />
</div>
<p className={styles.value}>{value}</p>
</div>
</main>
);
}05 スナップ選択ダイヤル — 近い目盛りへ弾けるように揃う方式 — 計測 累積面積 1.661% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · css ゲージ 9選 — 計算で回る円形コントロール
このデモが属する記事
css ゲージ 9選 — 計算で回る円形コントロール9つの円形コントロールのうち、1つはドラッグした角度の値が慣性で減衰しつまみが徐々に止まり、1つは区間の値が角度に変換されてcss ゲージのバーが円を描きながら満ち、1つはスナップ判定の値によって最も近い区間へ弾けるように揃います。9つとも この記事のデモをすべて見る


