02Picker Whose Radial Gauge Arc Fills to Match Its Value
The chosen value divided by the full range becomes an arc length, so turning the handle redraws the arc every frame to fill or shrink by that same ratio.
Best forAnalog value inputs like brightness and volume
STARTENDMath.atan2

<main class="stage">
<figure class="rg is-demo" id="rg">
<div class="rg__zone" id="zone">
<svg class="rg__chart" id="chart" viewBox="0 0 480 300" role="img" aria-label="A radial gauge whose arc fills to match the value's ratio; turning the handle redraws the arc every instant.">
<path class="rg__track" id="track"></path>
<path class="rg__fill" id="fill"></path>
<text class="rg__num" id="num" x="240" y="170" text-anchor="middle" dominant-baseline="central">62%</text>
</svg>
<button class="rg__grab" id="grab" type="button" role="slider" aria-orientation="horizontal"
aria-label="Choose level" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="62" aria-valuetext="Level 62%">
<svg class="rg__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="rg__badge" aria-hidden="true">Level <span id="rg-val">62</span>%</b>
</div>
<p class="rg__hint">Drag the handle or press arrow keys — arc length recomputes.</p>
<div class="sr">
<span aria-live="polite" id="rg-live"></span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// 방사형 게이지 피커 — 손잡이를 끌거나 화살표키로 값(0~100)을 정하면 원호 경로(d 속성)를
// 시작각→현재각으로 매 프레임 다시 그린다. 관성 lerp 로 값이 목표를 뒤따른다.
// CSS @keyframes 는 0개(각도가 매 순간 바뀌는 실수라 선언형으로 못 담는다, 398-01 과 같은 이유).
// picker-graph-gauge-radius(JS, R)가 게이지 반지름을 정한다.
$duration: $dur-loop;
$easing: $ease-lerp-settle;
$color: $subject-blue;
@include stage($stage-yellow);
.sr {
position: absolute; width: 1px; height: 1px; overflow: hidden;
clip-path: inset(50%); white-space: nowrap;
}
.rg {
box-sizing: border-box;
width: min(360px, 92vw);
margin: 0; padding: $sp-2;
display: flex; flex-direction: column; gap: $sp-2; align-items: center;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
color: $subject-ink;
}
.rg__zone {
position: relative;
width: min(352px, 100%);
aspect-ratio: 480 / 300;
}
.rg__chart { display: block; width: 100%; height: 100%; overflow: visible; }
.rg__track {
fill: none;
stroke: mix($subject-blue, $stage-paper, 16%);
stroke-width: 30;
stroke-linecap: round;
}
.rg__fill {
fill: none;
stroke: $subject-blue-deep;
stroke-width: 30;
stroke-linecap: round;
}
.rg__num {
font-size: 44px;
font-weight: 800;
font-variant-numeric: tabular-nums;
fill: $subject-ink;
}
.rg__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: $subject-blue-deep;
color: $stage-paper;
box-shadow: 0 0 0 4px $stage-paper, $shadow-press;
transform: translate(-50%, -50%);
cursor: grab;
touch-action: none;
}
.rg__grab:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 3px; }
.rg__ic { width: 14px; height: 14px; }
.rg__badge {
position: absolute; left: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $stage-paper; font-weight: 700; font-size: 11px;
font-variant-numeric: tabular-nums;
}
.rg__hint { margin: 0; padding: 0 $sp-1; font-size: 11px; line-height: 16px; color: rgba($subject-ink, .62); text-align: center; }
@media (max-width: 340px) {
.rg { padding: $sp-2; gap: $sp-1; }
.rg__hint { display: none; }
.rg__zone { width: min(240px, 100%); }
.rg__num { font-size: 32px; }
.rg__track, .rg__fill { stroke-width: 16; }
.rg__grab { width: 24px; height: 24px; }
.rg__badge { font-size: 10px; padding: 0 $sp-1; }
}
// reduce — rAF 루프를 시작하지 않는다. 입력은 current=value 즉시 반영 후 paint() 1회.
@media (prefers-reduced-motion: reduce) {
.rg.is-still .rg__fill { 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: #ffd23f;
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;
}
.rg {
box-sizing: border-box;
width: min(360px, 92vw);
margin: 0;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
color: #17141a;
}
.rg__zone {
position: relative;
width: min(352px, 100%);
aspect-ratio: 480/300;
}
.rg__chart {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
.rg__track {
fill: none;
stroke: rgb(86.9490196078%, 90.8392156863%, 99.4352941176%);
stroke-width: 30;
stroke-linecap: round;
}
.rg__fill {
fill: none;
stroke: #0a4fe8;
stroke-width: 30;
stroke-linecap: round;
}
.rg__num {
font-size: 44px;
font-weight: 800;
font-variant-numeric: tabular-nums;
fill: #17141a;
}
.rg__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: #0a4fe8;
color: #ffffff;
box-shadow: 0 0 0 4px #ffffff, 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translate(-50%, -50%);
cursor: grab;
touch-action: none;
}
.rg__grab:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 3px;
}
.rg__ic {
width: 14px;
height: 14px;
}
.rg__badge {
position: absolute;
left: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #ffffff;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.rg__hint {
margin: 0;
padding: 0 4px;
font-size: 11px;
line-height: 16px;
color: rgba(23, 20, 26, 0.62);
text-align: center;
}
@media (max-width: 340px) {
.rg {
padding: 8px;
gap: 4px;
}
.rg__hint {
display: none;
}
.rg__zone {
width: min(240px, 100%);
}
.rg__num {
font-size: 32px;
}
.rg__track, .rg__fill {
stroke-width: 16;
}
.rg__grab {
width: 24px;
height: 24px;
}
.rg__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rg.is-still .rg__fill {
transition: none !important;
}
}// 실제 동작: pointerdown/move/up 과 keydown(화살표·Home·End) 이 Value(0~100)을 바꾸면
// 손잡이 각도가 다시 계산되고, 원호 경로(d 속성)를 시작각→현재각으로 매 프레임 다시 그린다.
// 값은 매 프레임 목표값으로 관성 보간(lerp)된다 — CSS @keyframes 없음(어느 각도인지 매 순간
// 바뀌는 실수라 선언형으로 못 담는다, 395·398-01 과 같은 이유로 실시간 캡처 경로를 쓴다).
var root = document.getElementById('rg');
var zone = document.getElementById('zone');
var chart = document.getElementById('chart');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var numEl = document.getElementById('num');
var valEl = document.getElementById('rg-val');
var grab = document.getElementById('grab');
var live = document.getElementById('rg-live');
var CX = 240, CY = 170, R = 118; // picker-graph-gauge-radius — 게이지 반지름
var START = 135, SWEEP = 270; // 시작각( degrees)·전체 스윕각 — END = START + SWEEP
var END = START + SWEEP;
var LERP_RATE = 0.20;
var AUTO_PERIOD = 1.7;
var STEP = 5;
var value = 62;
var current = 62;
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 (Math.abs(a1 - a0) < 0.5) 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 + (SWEEP * v) / 100; }
function paint() {
track.setAttribute('d', arcPath(CX, CY, R, START, END));
fill.setAttribute('d', arcPath(CX, CY, R, START, angleOf(current)));
var p = polar(CX, CY, R, angleOf(current));
grab.style.left = (p.x / 480 * 100) + '%';
grab.style.top = (p.y / 300 * 100) + '%';
var rv = Math.round(current);
numEl.textContent = rv + '%';
valEl.textContent = String(rv);
grab.setAttribute('aria-valuenow', String(rv));
grab.setAttribute('aria-valuetext', 'Level ' + rv + '%');
}
function announce() {
live.textContent = 'Level ' + Math.round(current) + '% recomputed';
}
function setValue(v, isManual) {
value = Math.max(0, Math.min(100, 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;
if (deg > END) deg = (deg - END < START + 360 - deg) ? END : START;
return deg;
}
function fromPointer(clientX, clientY) {
var deg = angleFromPointer(clientX, clientY);
var v = (deg - START) / SWEEP * 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(0, true); e.preventDefault(); }
else if (e.key === 'End') { setValue(100, 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 + 48 * 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) * LERP_RATE;
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);
}// RadialGaugeArcFill — vanilla 판(run/398/demo/02-picker-graph-radial-gauge-arc-fill/index.html) 의 React 포트.
// 손잡이를 끌거나 화살표키로 값(0~100)을 정하면 원호 경로(d 속성)를 시작각→현재각으로
// 매 프레임 다시 그린다. 값은 관성 lerp 로 목표를 뒤따른다. CSS @keyframes 는 0개(vanilla 와 같은 이유).
import { useEffect, useRef } from "react";
import styles from "./RadialGaugeArcFill.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 = 170, R = 118; // picker-graph-gauge-radius — 게이지 반지름
const START = 135, SWEEP = 270;
const END = START + SWEEP;
const LERP_RATE = 0.20;
const AUTO_PERIOD = 1.7;
const STEP = 5;
function polar(cx: number, cy: number, r: number, deg: 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 (Math.abs(a1 - a0) < 0.5) return "";
const s = polar(cx, cy, r, a0), 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 + (SWEEP * v) / 100;
}
export default function RadialGaugeArcFill() {
const rootRef = useRef<HTMLElement | null>(null);
const zoneRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<SVGPathElement | null>(null);
const fillRef = useRef<SVGPathElement | null>(null);
const numRef = useRef<SVGTextElement | null>(null);
const grabRef = useRef<HTMLButtonElement | null>(null);
const valRef = useRef<HTMLElement | null>(null);
const liveRef = useRef<HTMLSpanElement | null>(null);
const value = useRef(62) as Box<number>;
const current = useRef(62) 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 {
trackRef.current?.setAttribute("d", arcPath(CX, CY, R, START, END));
fillRef.current?.setAttribute("d", arcPath(CX, CY, R, START, angleOf(current.current)));
const p = polar(CX, CY, R, angleOf(current.current));
const grab = grabRef.current;
if (grab) {
grab.style.left = (p.x / 480) * 100 + "%";
grab.style.top = (p.y / 300) * 100 + "%";
}
const rv = Math.round(current.current);
if (numRef.current) numRef.current.textContent = rv + "%";
if (valRef.current) valRef.current.textContent = String(rv);
if (grab) {
grab.setAttribute("aria-valuenow", String(rv));
grab.setAttribute("aria-valuetext", "레벨 " + rv + "%");
}
}
function announce(): void {
if (liveRef.current) liveRef.current.textContent = "레벨 " + Math.round(current.current) + "%로 다시 계산됨";
}
function setValue(v: number, isManual: boolean): void {
value.current = Math.max(0, Math.min(100, 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;
if (deg > END) deg = deg - END < START + 360 - deg ? END : START;
return deg;
}
function fromPointer(clientX: number, clientY: number): void {
const deg = angleFromPointer(clientX, clientY);
const v = ((deg - START) / SWEEP) * 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(0, true); e.preventDefault(); }
else if (e.key === "End") { setValue(100, 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 + 48 * 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) * LERP_RATE;
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.rg + " 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="값의 비율만큼 원호가 채워지는 방사형 게이지, 손잡이를 돌리면 원호가 매 순간 다시 그려진다"
>
<path className={styles.track} ref={trackRef} />
<path className={styles.fill} ref={fillRef} />
<text className={styles.num} ref={numRef} x={240} y={170} textAnchor="middle" dominantBaseline="central">62%</text>
</svg>
<button
className={styles.grab}
ref={grabRef}
type="button"
role="slider"
aria-orientation="horizontal"
aria-label="레벨 고르기"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={62}
aria-valuetext="레벨 62%"
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=".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}>62</span>%</b>
</div>
<p className={styles.hint}>손잡이를 끌거나 화살표키를 누르면 원호 길이가 다시 계산됩니다</p>
<div className={styles.sr}>
<span aria-live="polite" ref={liveRef} />
</div>
</figure>
</main>
);
}02 Picker Whose Radial Gauge Arc Fills to Match Its Value — Measured area 9.973% · 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
Pickers02 A Waveform Whose Fast-Attack, Slow-Release Follower Fills a Smooth Loudness OutlineCSS Waveform: 9 Peak Columns and Trim Pickers
Pickers01 Round dial you turn by the hour hand9 time picker ui patterns, from a point to a span