07Picker That Turns Its Value Into an Orbit's Arc Length
The angle the handle is turned becomes a value, that value is recomputed into a point's position on the circle, and the orbit arc reaching that point is redrawn to match the value every frame.
Best forTimers and rotary-dial style setting inputs
ARC_LERPMath.atan2clip-path

<main class="stage">
<figure class="od is-demo" id="od">
<div class="od__zone" id="zone">
<svg class="od__chart" id="chart" viewBox="0 0 480 300" role="img" aria-label="An orbit dial that travels around the circle; turning the handle redraws the arc from the start point to the point every instant.">
<circle class="od__orbit" cx="240" cy="150" r="106"></circle>
<path class="od__arc" id="arc"></path>
<circle class="od__dot" id="dot" r="19"></circle>
</svg>
<button class="od__grab" id="grab" type="button" role="slider" aria-orientation="horizontal"
aria-label="Choose orbit angle" aria-valuemin="1" aria-valuemax="99"
aria-valuenow="35" aria-valuetext="Orbit 126 degrees">
<svg class="od__ic" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><circle cx="128" cy="128" r="96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88 88.1 88.1 0 0 1-88 88"/></g></svg>
</button>
<b class="od__badge" aria-hidden="true"><span id="od-val">126</span>°</b>
</div>
<p class="od__hint">Drag the handle or press arrow keys — orbit arc recomputes.</p>
<div class="sr">
<span aria-live="polite" id="od-live"></span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 오빗 다이얼 피커 — 손잡이를 돌린 각도(1~99, 도 단위)를 값으로 바꾸면, 원 둘레 위
// 점의 위치를 극좌표로 다시 계산하고 시작점(위쪽)부터 그 점까지 이어진 궤도 호의
// 길이(path d 속성)를 매 프레임 다시 그린다. 값은 관성 lerp(ARC_LERP)로 목표를 뒤따른다.
// CSS @keyframes 는 0개(각도가 매 프레임 실수로 바뀌어 선언형으로 못 담는다,
// 398-01~06 과 같은 이유).
// picker-graph-orbit-dial-arc-smoothing(JS, ARC_LERP)이 변형 07 의 조정 대상.
$duration: $dur-loop;
$easing: $ease-lerp-settle;
$color: $subject-blue;
@include stage($stage-orange);
.sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap;
}
.od {
box-sizing: border-box;
width: min(360px, 92vw);
margin: 0; padding: $sp-1;
display: flex; flex-direction: column; align-items: center; gap: $sp-1;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
color: $subject-ink;
}
.od__zone {
position: relative;
width: min(352px, 100%);
aspect-ratio: 480 / 300;
}
.od__chart { display: block; width: 100%; height: 100%; overflow: visible; }
.od__orbit { fill: none; stroke: rgba($subject-ink, .14); stroke-width: 2; }
.od__arc { fill: none; stroke: $subject-blue-deep; stroke-width: 40; stroke-linecap: round; }
.od__dot { fill: $subject-blue-deep; stroke: $stage-paper; stroke-width: 4; }
.od__grab {
position: absolute; top: 0; left: 0;
width: 30px; height: 30px;
margin: 0; padding: 0; border: 0; border-radius: 50%;
display: grid; place-items: center;
background: transparent;
color: $subject-blue-deep;
transform: translate(-50%, -50%);
cursor: grab;
touch-action: none;
}
.od__grab:active { cursor: grabbing; }
.od__grab:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; border-radius: 50%; }
.od__ic { width: 15px; height: 15px; opacity: 0; }
.od__badge {
position: absolute; top: $sp-2; right: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba($subject-ink, .06);
font-size: 13px; line-height: 18px; font-weight: 800;
font-variant-numeric: tabular-nums;
color: $subject-blue-deep;
pointer-events: none;
}
.od__hint { margin: 0; padding: 0 $sp-2; font-size: 11px; line-height: 16px; color: rgba($subject-ink, .62); text-align: center; }
@media (max-width: 340px) {
.od { padding: $sp-1; gap: $sp-1; }
.od__hint { display: none; }
.od__badge { font-size: 12px; }
.od__zone { width: min(210px, 100%); }
}
@media (prefers-reduced-motion: reduce) {
.od.is-still .od__arc,
.od.is-still .od__dot { transition: none !important; }
}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);
}
.sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.od {
box-sizing: border-box;
width: min(360px, 92vw);
margin: 0;
padding: 4px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
color: #17141a;
}
.od__zone {
position: relative;
width: min(352px, 100%);
aspect-ratio: 480/300;
}
.od__chart {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
.od__orbit {
fill: none;
stroke: rgba(23, 20, 26, 0.14);
stroke-width: 2;
}
.od__arc {
fill: none;
stroke: #0a4fe8;
stroke-width: 40;
stroke-linecap: round;
}
.od__dot {
fill: #0a4fe8;
stroke: #ffffff;
stroke-width: 4;
}
.od__grab {
position: absolute;
top: 0;
left: 0;
width: 30px;
height: 30px;
margin: 0;
padding: 0;
border: 0;
border-radius: 50%;
display: grid;
place-items: center;
background: transparent;
color: #0a4fe8;
transform: translate(-50%, -50%);
cursor: grab;
touch-action: none;
}
.od__grab:active {
cursor: grabbing;
}
.od__grab:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
border-radius: 50%;
}
.od__ic {
width: 15px;
height: 15px;
opacity: 0;
}
.od__badge {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.06);
font-size: 13px;
line-height: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0a4fe8;
pointer-events: none;
}
.od__hint {
margin: 0;
padding: 0 8px;
font-size: 11px;
line-height: 16px;
color: rgba(23, 20, 26, 0.62);
text-align: center;
}
@media (max-width: 340px) {
.od {
padding: 4px;
gap: 4px;
}
.od__hint {
display: none;
}
.od__badge {
font-size: 12px;
}
.od__zone {
width: min(210px, 100%);
}
}
@media (prefers-reduced-motion: reduce) {
.od.is-still .od__arc,
.od.is-still .od__dot {
transition: none !important;
}
}// 실제 동작: pointerdown/move/up 과 keydown 이 각도값(1~99, degrees 단위로 환산)을 바꾸면,
// 원 둘레 위 점의 위치를 극좌표로 다시 계산하고, 시작점(위쪽)부터 그 점까지 이어진
// Orbit 호의 길이(path d 속성)를 매 프레임 다시 그린다. 값은 관성 lerp(ARC_LERP)로
// 목표를 뒤따른다. CSS @keyframes 없음(각도가 매 프레임 실수로 바뀌어 선언형으로
// 못 담는다, 398-01~06 과 같은 이유).
var root = document.getElementById('od');
var zone = document.getElementById('zone');
var arcEl = document.getElementById('arc');
var dotEl = document.getElementById('dot');
var grab = document.getElementById('grab');
var valEl = document.getElementById('od-val');
var live = document.getElementById('od-live');
var CX = 240, CY = 150, R = 106; // picker-graph-orbit-radius — Orbit 반지름
var START = -90;
var MIN_V = 1, MAX_V = 99;
var ARC_LERP = 0.18; // picker-graph-orbit-dial-arc-smoothing — 변형 07 의 조정 대상
var AUTO_PERIOD = 1.5;
var STEP = 5;
var value = 35;
var current = 35;
var manual = false;
var autoT = 0;
var lastTs = 0;
var zoneRect = null;
var rafId = 0;
function reduced() {
return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function polar(cx, cy, r, deg) {
var rad = deg * Math.PI / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function arcPath(cx, cy, r, a0, a1) {
if (a1 - a0 < 0.6) return '';
var s = polar(cx, cy, r, a0), e = polar(cx, cy, r, a1);
var large = (a1 - a0) > 180 ? 1 : 0;
return 'M ' + s.x + ' ' + s.y + ' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' + e.x + ' ' + e.y;
}
function angleOf(v) { return START + (v / 100) * 360; }
function paint() {
var deg = angleOf(current);
arcEl.setAttribute('d', arcPath(CX, CY, R, START, deg));
var p = polar(CX, CY, R, deg);
dotEl.setAttribute('cx', String(p.x));
dotEl.setAttribute('cy', String(p.y));
grab.style.left = (p.x / 480 * 100) + '%';
grab.style.top = (p.y / 300 * 100) + '%';
var rd = Math.round(deg - START);
valEl.textContent = String(rd);
grab.setAttribute('aria-valuenow', String(Math.round(current)));
grab.setAttribute('aria-valuetext', 'Orbit ' + rd + ' degrees');
}
function announce() {
live.textContent = 'Orbit ' + Math.round(angleOf(current) - START) + ' degrees recomputed';
}
function setValue(v, isManual) {
value = Math.max(MIN_V, Math.min(MAX_V, v));
if (isManual) { manual = true; root.classList.remove('is-demo'); }
}
function angleFromPointer(clientX, clientY) {
if (!zoneRect) zoneRect = zone.getBoundingClientRect();
var cx = zoneRect.left + zoneRect.width * (CX / 480);
var cy = zoneRect.top + zoneRect.height * (CY / 300);
var deg = Math.atan2(clientY - cy, clientX - cx) * 180 / Math.PI;
while (deg < START) deg += 360;
while (deg >= START + 360) deg -= 360;
return deg;
}
function fromPointer(clientX, clientY) {
var deg = angleFromPointer(clientX, clientY);
var v = (deg - START) / 360 * 100;
setValue(v, true);
}
function onDown(e) {
zoneRect = zone.getBoundingClientRect();
if (grab.setPointerCapture) { try { grab.setPointerCapture(e.pointerId); } catch (err) {} }
fromPointer(e.clientX, e.clientY);
if (reduced()) { current = value; paint(); announce(); }
}
function onMove(e) { fromPointer(e.clientX, e.clientY); if (reduced()) { current = value; paint(); announce(); } }
function onUp() { announce(); }
function onZoneDown(e) {
zoneRect = zone.getBoundingClientRect();
fromPointer(e.clientX, e.clientY);
if (reduced()) { current = value; paint(); announce(); }
}
function onKey(e) {
var map = { ArrowLeft: -STEP, ArrowDown: -STEP, ArrowRight: STEP, ArrowUp: STEP };
if (e.key === 'Home') { setValue(MIN_V, true); e.preventDefault(); }
else if (e.key === 'End') { setValue(MAX_V, true); e.preventDefault(); }
else if (map[e.key] !== undefined) { setValue(value + map[e.key], true); e.preventDefault(); }
else { return; }
if (reduced()) { current = value; paint(); announce(); }
}
function onResize() { zoneRect = zone.getBoundingClientRect(); if (reduced()) paint(); }
function autoStep(dt) {
autoT += dt;
var omega = 2 * Math.PI / AUTO_PERIOD;
value = 50 + 46 * Math.sin(autoT * omega);
}
function frame(ts) {
if (!lastTs) lastTs = ts;
var dt = Math.min(0.05, (ts - lastTs) / 1000);
lastTs = ts;
if (!manual) autoStep(dt);
current += (value - current) * ARC_LERP;
paint();
rafId = requestAnimationFrame(frame);
}
grab.addEventListener('pointerdown', onDown);
grab.addEventListener('pointermove', onMove);
grab.addEventListener('pointerup', onUp);
grab.addEventListener('keydown', onKey);
zone.addEventListener('pointerdown', onZoneDown);
window.addEventListener('resize', onResize);
if (reduced()) {
root.classList.add('is-still');
current = value;
paint();
} else {
paint();
rafId = requestAnimationFrame(frame);
}// OrbitDialArcLength — vanilla 판(run/398/demo/07-picker-graph-orbit-dial-arc-length/index.html) 의
// React 포트. 손잡이를 돌린 각도를 값으로 바꾸면, 원 둘레 위 점의 위치를 극좌표로 다시
// 계산하고 시작점(위쪽)부터 그 점까지 이어진 궤도 호의 길이를 매 프레임 값에 맞춰 다시 그린다.
import { useEffect, useRef } from "react";
import styles from "./OrbitDialArcLength.module.scss";
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type PtrEvt = { clientX: number; clientY: number; pointerId: number };
const CX = 240, CY = 150, R = 106; // picker-graph-orbit-radius — 궤도 반지름
const START = -90;
const MIN_V = 1, MAX_V = 99;
const ARC_LERP = 0.18; // picker-graph-orbit-dial-arc-smoothing — 변형 07 의 조정 대상
const AUTO_PERIOD = 1.5;
const STEP = 5;
function polar(cx: number, cy: number, r: number, deg: number): { x: number; y: number } {
const rad = (deg * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function arcPath(cx: number, cy: number, r: number, a0: number, a1: number): string {
if (a1 - a0 < 0.6) return "";
const s = polar(cx, cy, r, a0);
const e = polar(cx, cy, r, a1);
const large = a1 - a0 > 180 ? 1 : 0;
return "M " + s.x + " " + s.y + " A " + r + " " + r + " 0 " + large + " 1 " + e.x + " " + e.y;
}
function angleOf(v: number): number {
return START + (v / 100) * 360;
}
export default function OrbitDialArcLength() {
const rootRef = useRef<HTMLElement | null>(null);
const zoneRef = useRef<HTMLDivElement | null>(null);
const arcRef = useRef<SVGPathElement | null>(null);
const dotRef = useRef<SVGCircleElement | null>(null);
const grabRef = useRef<HTMLButtonElement | null>(null);
const valRef = useRef<HTMLElement | null>(null);
const liveRef = useRef<HTMLSpanElement | null>(null);
const value = useRef(35) as Box<number>;
const current = useRef(35) as Box<number>;
const manual = useRef(false) as Box<boolean>;
const autoT = useRef(0) as Box<number>;
const lastTs = useRef(0) as Box<number>;
const zoneRect = useRef<DOMRect | null>(null) as Box<DOMRect | null>;
const rafId = useRef(0) as Box<number>;
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function paint(): void {
const deg = angleOf(current.current);
arcRef.current?.setAttribute("d", arcPath(CX, CY, R, START, deg));
const p = polar(CX, CY, R, deg);
dotRef.current?.setAttribute("cx", String(p.x));
dotRef.current?.setAttribute("cy", String(p.y));
const grab = grabRef.current;
if (grab) {
grab.style.left = (p.x / 480) * 100 + "%";
grab.style.top = (p.y / 300) * 100 + "%";
grab.setAttribute("aria-valuenow", String(Math.round(current.current)));
grab.setAttribute("aria-valuetext", "궤도 " + Math.round(deg - START) + "도");
}
const rd = Math.round(deg - START);
if (valRef.current) valRef.current.textContent = String(rd);
}
function announce(): void {
if (liveRef.current) {
liveRef.current.textContent = "궤도 " + Math.round(angleOf(current.current) - START) + "도로 다시 계산됨";
}
}
function setValue(v: number, isManual: boolean): void {
value.current = Math.max(MIN_V, Math.min(MAX_V, v));
if (isManual) {
manual.current = true;
rootRef.current?.classList.remove("is-demo");
}
}
function angleFromPointer(clientX: number, clientY: number): number {
if (!zoneRect.current && zoneRef.current) zoneRect.current = zoneRef.current.getBoundingClientRect();
const rect = zoneRect.current;
if (!rect) return START;
const cx = rect.left + rect.width * (CX / 480);
const cy = rect.top + rect.height * (CY / 300);
let deg = (Math.atan2(clientY - cy, clientX - cx) * 180) / Math.PI;
while (deg < START) deg += 360;
while (deg >= START + 360) deg -= 360;
return deg;
}
function fromPointer(clientX: number, clientY: number): void {
const deg = angleFromPointer(clientX, clientY);
const v = ((deg - START) / 360) * 100;
setValue(v, true);
}
function reflectNow(): void {
current.current = value.current;
paint();
announce();
}
function onDown(e: PtrEvt): void {
if (zoneRef.current) zoneRect.current = zoneRef.current.getBoundingClientRect();
const grab = grabRef.current;
if (grab && typeof grab.setPointerCapture === "function") {
try { grab.setPointerCapture(e.pointerId); } catch { /* 캡처 실패는 치명적이지 않다 */ }
}
fromPointer(e.clientX, e.clientY);
if (reduced()) reflectNow();
}
function onMove(e: PtrEvt): void {
fromPointer(e.clientX, e.clientY);
if (reduced()) reflectNow();
}
function onUp(): void {
announce();
}
function onZoneDown(e: PtrEvt): void {
if (zoneRef.current) zoneRect.current = zoneRef.current.getBoundingClientRect();
fromPointer(e.clientX, e.clientY);
if (reduced()) reflectNow();
}
function onKey(e: KeyEvt): void {
const map: Record<string, number> = { ArrowLeft: -STEP, ArrowDown: -STEP, ArrowRight: STEP, ArrowUp: STEP };
if (e.key === "Home") { setValue(MIN_V, true); e.preventDefault(); }
else if (e.key === "End") { setValue(MAX_V, true); e.preventDefault(); }
else if (map[e.key] !== undefined) { setValue(value.current + map[e.key], true); e.preventDefault(); }
else { return; }
if (reduced()) reflectNow();
}
function onResize(): void {
if (zoneRef.current) zoneRect.current = zoneRef.current.getBoundingClientRect();
if (reduced()) paint();
}
function autoStep(dt: number): void {
autoT.current += dt;
const omega = (2 * Math.PI) / AUTO_PERIOD;
value.current = 50 + 46 * Math.sin(autoT.current * omega);
}
function frame(ts: number): void {
if (!lastTs.current) lastTs.current = ts;
const dt = Math.min(0.05, (ts - lastTs.current) / 1000);
lastTs.current = ts;
if (!manual.current) autoStep(dt);
current.current += (value.current - current.current) * ARC_LERP;
paint();
rafId.current = requestAnimationFrame(frame);
}
useEffect(() => {
window.addEventListener("resize", onResize);
if (reduced()) {
rootRef.current?.classList.add("is-still");
reflectNow();
} else {
paint();
rafId.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafId.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<figure className={styles.od + " is-demo"} ref={rootRef}>
<div className={styles.zone} ref={zoneRef} onPointerDown={onZoneDown}>
<svg
className={styles.chart}
viewBox="0 0 480 300"
role="img"
aria-label="원 둘레를 도는 오빗 다이얼, 손잡이를 돌리면 시작점부터 점까지 이어진 궤도 호의 길이가 매 순간 다시 그려진다"
>
<circle className={styles.orbit} cx={CX} cy={CY} r={R} />
<path className={styles.arc} ref={arcRef} />
<circle className={styles.dot} ref={dotRef} r={19} />
</svg>
<button
className={styles.grab}
ref={grabRef}
type="button"
role="slider"
aria-orientation="horizontal"
aria-label="궤도 각도 고르기"
aria-valuemin={MIN_V}
aria-valuemax={MAX_V}
aria-valuenow={35}
aria-valuetext="궤도 126도"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onKeyDown={onKey}
>
<svg className={styles.ic} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<circle cx={128} cy={128} r={96} opacity={0.2} />
<path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88 88.1 88.1 0 0 1-88 88" />
</g>
</svg>
</button>
<b className={styles.badge} aria-hidden="true"><span ref={valRef}>126</span>°</b>
</div>
<p className={styles.hint}>손잡이를 끌거나 화살표키를 누르면 궤도 호의 길이가 다시 계산됩니다</p>
<div className={styles.sr}>
<span aria-live="polite" ref={liveRef} />
</div>
</figure>
</main>
);
}07 Picker That Turns Its Value Into an Orbit's Arc Length — Measured area 9.642% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Graph Animation CSS Pickers With Live Recompute
The post this demo lives in
9 Graph Animation CSS Pickers With Live RecomputeMove a bar by pixel and the graph drifts from its value. So nine pickers recompute: bar height from a bucket value, a ga See all demos in this post
If you like this effect - similar demos
Pickers02 Hue wheel that reads the angle you pressCustom Color Picker: 9 JS Parts, No Library
Pickers07 Sheet-swap value toggle — as the screen-width value narrows, a popover value changes into a bottom-sheet value that rises from belowDate Picker UI: 9 Popover & Calendar Patterns
Pickers01 Round dial you turn by the hour hand9 time picker ui patterns, from a point to a span