02값만큼 원호가 채워지는 방사형 게이지 피커
선택한 값을 전체 범위로 나눈 비율을 원호 길이로 바꿔, 손잡이를 돌릴 때마다 그 비율만큼 원호가 채워지거나 줄어들도록 매 프레임 다시 그린다.
쓰임밝기·볼륨 같은 아날로그 값 입력
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="값의 비율만큼 원호가 채워지는 방사형 게이지, 손잡이를 돌리면 원호가 매 순간 다시 그려진다">
<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="레벨 고르기" aria-valuemin="0" aria-valuemax="100"
aria-valuenow="62" aria-valuetext="레벨 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">레벨 <span id="rg-val">62</span>%</b>
</div>
<p class="rg__hint">손잡이를 끌거나 화살표키를 누르면 원호 길이가 다시 계산됩니다</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) 이 값(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; // 시작각(도)·전체 스윕각 — 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', '레벨 ' + rv + '%');
}
function announce() {
live.textContent = '레벨 ' + Math.round(current) + '%로 다시 계산됨';
}
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 값만큼 원호가 채워지는 방사형 게이지 피커 — 측정 누적면적 9.973% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 그래프 애니메이션 css 피커 9가지 — 값 하나로 다시 그리기
이 데모가 속한 글
그래프 애니메이션 css 피커 9가지 — 값 하나로 다시 그리기그래프 애니메이션 css 아홉 개를 나란히 두고 보면 막대를 픽셀 위치로만 옮기면 그래프가 실제 값과 어긋난다 — 그래서 값 하나로 히스토그램 막대 높이를 다시 계산하는 피커, 원호 길이를 채우는 게이지 피커, 지나 이 글의 데모 전부 보기


