css 게이지 바 9종 — 손잡이가 계산으로 도는 원형 컨트롤
아홉 개의 원형 컨트롤 중 하나는 드래그 각도 값이 관성으로 감쇠하며 손잡이가 서서히 멈추고, 하나는 각 구간 값이 각도로 변환되어 css 게이지 바가 원을 그리며 채워지고, 하나는 스냅 판정 값에 따라 가장 가까운 구간으로 튕기듯 정렬됩니다.
트리거는 항목마다 다릅니다 — 01 은 열기 click·닫기 Escape keydown, 02 는 pointerdown/move/up 에 방향키 keydown, 03 은 클릭 없이 pointermove 와 pointerleave 만, 04 는 pointercancel 까지 받고, 05 는 pointerdown/move/up, 06 은 click 두 버튼, 07 은 pointerdown/up/leave 에 Enter·Space keydown, 08 은 pointerdown/move/up 에 방향키 keydown, 09 는 keydown 이 먼저고 pointerdown 이 초점을 옮깁니다. 자동 시연은 .is-demo 클래스가 붙어 있고 prefers-reduced-motion 이 거짓일 때만 돌다가 첫 사용자 입력에 풀리는데, 04·05·06·07 은 감쇠·정착·보간·스프링 계산 자체에 reduce 검사가 박혀 있어 자동 시연 조건과 무관하게 실사용 중에도 설정을 계속 지키고, 나머지 다섯은 실사용 전환을 css prefers-reduced-motion 블록이 대신 끊습니다. Math.random 은 한 곳도 쓰지 않아 같은 조작은 언제나 같은 결과를 냅니다.
01 arc-radial-menu
02 arc-genre-wheel
03 arc-segment-hover
04 arc-drag-rotate
05 arc-snap-select
06 arc-conic-progress
07 arc-knob-dial
08 arc-mood-slider
09 arc-keyboard-a11y
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 방사형 메뉴 — 중심에서 부채꼴로 펼쳐지는 원형 메뉴
- 02 장르 선택 휠 — 카테고리를 원판처럼 돌려 고르는 방식
- 03 구간 호버 링 — 마우스가 지나는 구간만 도드라지는 원형 띠
- 04 드래그 관성 손잡이 — 손을 떼도 서서히 멈추는 회전 손잡이
- 05 스냅 선택 다이얼 — 가까운 눈금으로 튕기듯 정렬되는 방식
- 06 원형 게이지 바 — 값에 따라 각도로 채워지는 원형 진행률 표시
- 07 물리형 노브 다이얼 — 손잡이 자체가 눌리고 튀는 질감의 다이얼
- 08 원호 무드 슬라이더 — 반원을 따라 강도를 고르는 손잡이
- 09 키보드 조작 원형 컨트롤 — 화살표 키로 눈금을 옮기는 접근성 다이얼
아홉 개의 해부 — 어떤 계산이 원형 컨트롤을 움직이는가
아홉 컨트롤을 각각 24장으로 캡처해 세 지표로 비교했습니다 — 얼마나 넓은 화면이 바뀌었는지(누적면적), 프레임끼리 픽셀 차를 얼마나 셌는지(강도), 캡처 24장 중 실제로 움직임이 잡힌 장 수(움직인프레임)입니다.
| 항목 | 누적면적% | 강도_평균 | 움직인프레임 |
|---|---|---|---|
| 01 | 11.25% | 98.5 | 12/23 |
| 02 | 23.78% | 120.2 | 23/23 |
| 03 | 11.20% | 75.6 | 22/23 |
| 04 | 17.07% | 55.4 | 17/23 |
| 05 | 1.66% | 153.9 | 18/23 |
| 06 | 5.93% | 181.2 | 23/23 |
| 07 | 18.51% | 54.2 | 12/23 |
| 08 | 8.37% | 88.8 | 23/23 |
| 09 | 2.96% | 150.8 | 15/23 |
누적면적 1위는 02(23.78%)입니다 — 원판 전체가 돌며 여러 장르의 크기·선명도가 함께 바뀌니 픽셀이 바뀌는 폭이 가장 넓습니다. 꼴찌는 05(1.66%)로 손잡이가 눈금 사이 짧은 거리만 움직이기 때문이고, 그 앞의 09(2.96%)도 손잡이 하나가 원호를 따라 이동하는 좁은 변화입니다. 강도가 180 을 넘는 항목은 06(181.2) 하나뿐인데, 고리가 채워지는 순간 대비가 높은 픽셀 변화가 좁은 영역에 집중되어서입니다. 최저는 07(54.2)로 스프링으로 흔들리는 손잡이의 변화가 완만하기 때문이고, 움직인 프레임은 02·06·08 이 23/23 로 가장 많으며 01·07 이 12/23 로 가장 적습니다 — 이는 흠이 아니라 단계 사이 머무는 정지 구간의 길이가 다를 뿐입니다.
값을 구하는 경로로 묶으면 네 부류로 갈립니다.
좌표를 각도에서 만드는 01·08
01 은 항목 i 의 각도를 ARC_SPAN_DEG(200) 안에 균등 분할해 cos·sin 으로 좌표를 내고, 08 은 포인터 각도를 atan2 로 구해 0~1 로 정규화한 뒤 다시 cos·sin 으로 손잡이 좌표를 냅니다. 둘 다 "어디에 놓을지"를 각도에서 시작해 좌표로 끝내는 축입니다.
나머지 연산이 값을 고르는 02·03·09
02 는 회전각을 ((-rotation % 360) + 360) % 360 으로 정규화해 활성 인덱스를 구하고, 03 은 포인터 각도를 360/N 폭으로 나눠 구간을 판정하며, 09 는 값-각도 비율 stepPct 로 키보드 한 걸음의 크기를 정합니다. 돌아온 값을 0~N 안으로 접어 넣는 나머지가 공통 분모입니다.
손을 뗀 뒤를 계산하는 04·05·07
04 는 놓은 순간의 속도에 매 프레임 DRAG_DECAY(0.92) 를 곱해 줄이고, 05 는 눈금까지의 거리를 재 easeOutBack 곡선으로 정착하며, 07 은 목표와 현재의 차에 스프링 상수를 곱해 힘으로 반영합니다. 드래그가 끝난 뒤에도 프레임마다 다시 도는 계산이라는 점이 같습니다.
표시 값을 끊어 보이게 하는 06
06 은 quantizedDeg() 가 퍼센트를 각도로 바꾼 뒤 GAUGE_STEP_DEG(6) 배수로 끊어 내리고, 채움 속도는 shown += (value - shown) * 0.16 보간으로 따로 움직입니다. 각도 해상도와 속도를 두 겹으로 분리해 어느 한쪽을 바꿔도 다른 쪽이 그대로 남는 축입니다.
패턴별 코드
01방사형 메뉴 — 중심에서 부채꼴로 펼쳐지는 원형 메뉴
항목 i 의 각도를 -90 - ARC_SPAN_DEG/2 + i * (ARC_SPAN_DEG/(개수-1)) 로 구해 위쪽을 중심으로 200 도 부채꼴을 균등 분할하고, RADIUS(96px) 만큼 떨어진 좌표를 cos·sin 으로 계산합니다. 좌표는 열림 여부와 무관하게 한 번만 구해지고 열림은 --stagger 지연만 차이를 냅니다.
// 계산 겹: 극좌표 → 데카르트 좌표. 항목 i 는 위쪽(-90도)을 중심으로 ARC_SPAN_DEG 를 균등 분할한
// 각도에서 RADIUS 만큼 떨어진 자리를 차지한다. 이 좌표는 열림 여부와 무관하게 한 번만 계산한다.
var items = ACTIONS.map(function (label, i) {
var li = document.createElement('li');
li.className = 'rm__item';
li.setAttribute('role', 'none');
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'rm__itemBtn';
btn.setAttribute('role', 'menuitem');
btn.tabIndex = -1;
btn.textContent = label;
var angleDeg = -90 - ARC_SPAN_DEG / 2 + i * (ARC_SPAN_DEG / (ACTIONS.length - 1));
var rad = angleDeg * Math.PI / 180;
var x = Math.round(Math.cos(rad) * RADIUS);
var y = Math.round(Math.sin(rad) * RADIUS);
li.style.setProperty('--x', x);
li.style.setProperty('--y', y);
li.style.setProperty('--stagger', i);
li.appendChild(btn);
list.appendChild(li);

<main class="stage">
<div class="rm is-demo" id="rm">
<div class="rm__field" id="rmField">
<ul class="rm__items" id="rmItems" role="menu" aria-hidden="true"></ul>
<button class="rm__fab" id="rmFab" type="button" aria-haspopup="true" aria-expanded="false">메뉴 열기</button>
</div>
<p class="rm__status" id="rmStatus">선택: 없음</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 방사형 메뉴 — 계산 겹은 index.html 의 극좌표→데카르트 변환(cos·sin, ARC_SPAN_DEG·RADIUS)에 있다.
// 여기서는 그 결과(--x·--y, 단위 없는 px 수)를 transform 으로 옮기기만 한다.
// 열림 여부는 .rm--open 클래스 하나 — 항목마다 --stagger 로 늦게 열리게 transition-delay 를 준다.
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.rm { display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.rm__field {
position: relative;
width: 280px;
height: 200px;
--field-scale: 1;
}
.rm__items {
position: absolute;
left: 50%;
bottom: 6px;
margin: 0;
padding: 0;
list-style: none;
width: 0;
height: 0;
}
.rm__item {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, -50%)
translate(calc(var(--x) * 1px * var(--field-scale, 1)), calc(var(--y) * 1px * var(--field-scale, 1)))
scale(0);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
transition-delay: 0s;
}
.rm--open .rm__item {
transform: translate(-50%, -50%)
translate(calc(var(--x) * 1px * var(--field-scale, 1)), calc(var(--y) * 1px * var(--field-scale, 1)))
scale(1);
opacity: 1;
transition-delay: calc(var(--stagger, 0) * 28ms);
}
.rm__itemBtn {
appearance: none;
border: none;
width: 56px;
height: 56px;
border-radius: 50%;
background: $color;
color: $subject-cream;
font-weight: 700;
font-size: 10.5px;
line-height: 1.2;
cursor: pointer;
box-shadow: $shadow-raised;
}
.rm__itemBtn:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.rm__itemBtn:hover { background: $subject-blue-deep; }
.rm__fab {
position: absolute;
left: 50%;
bottom: 6px;
transform: translate(-50%, 50%);
appearance: none;
border: none;
width: 64px;
height: 64px;
border-radius: 50%;
background: $subject-cream;
color: $subject-ink;
font-weight: 800;
font-size: 11px;
cursor: pointer;
box-shadow: $shadow-raised;
transition: transform $dur-instant $ease-out;
}
.rm__fab:hover { transform: translate(-50%, 50%) scale(1.06); }
.rm--open .rm__fab { transform: translate(-50%, 50%) rotate(45deg); }
.rm__fab:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
.rm__status {
margin: 0;
color: $subject-cream;
font-size: 11px;
font-weight: 600;
opacity: .8;
}
@media (max-width: 340px) {
.rm { gap: $sp-1; }
.rm__field { width: 220px; height: 144px; --field-scale: .64; }
.rm__itemBtn { width: 42px; height: 42px; font-size: 9px; }
.rm__fab { width: 48px; height: 48px; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.rm__item, .rm__fab { 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);
}
.rm {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.rm__field {
position: relative;
width: 280px;
height: 200px;
--field-scale: 1;
}
.rm__items {
position: absolute;
left: 50%;
bottom: 6px;
margin: 0;
padding: 0;
list-style: none;
width: 0;
height: 0;
}
.rm__item {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, -50%) translate(calc(var(--x) * 1px * var(--field-scale, 1)), calc(var(--y) * 1px * var(--field-scale, 1))) scale(0);
opacity: 0;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
transition-delay: 0s;
}
.rm--open .rm__item {
transform: translate(-50%, -50%) translate(calc(var(--x) * 1px * var(--field-scale, 1)), calc(var(--y) * 1px * var(--field-scale, 1))) scale(1);
opacity: 1;
transition-delay: calc(var(--stagger, 0) * 28ms);
}
.rm__itemBtn {
appearance: none;
border: none;
width: 56px;
height: 56px;
border-radius: 50%;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 10.5px;
line-height: 1.2;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.rm__itemBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.rm__itemBtn:hover {
background: #0a4fe8;
}
.rm__fab {
position: absolute;
left: 50%;
bottom: 6px;
transform: translate(-50%, 50%);
appearance: none;
border: none;
width: 64px;
height: 64px;
border-radius: 50%;
background: #fff7e6;
color: #17141a;
font-weight: 800;
font-size: 11px;
cursor: pointer;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: transform 140ms ease-out;
}
.rm__fab:hover {
transform: translate(-50%, 50%) scale(1.06);
}
.rm--open .rm__fab {
transform: translate(-50%, 50%) rotate(45deg);
}
.rm__fab:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.rm__status {
margin: 0;
color: #fff7e6;
font-size: 11px;
font-weight: 600;
opacity: 0.8;
}
@media (max-width: 340px) {
.rm {
gap: 4px;
}
.rm__field {
width: 220px;
height: 144px;
--field-scale: .64;
}
.rm__itemBtn {
width: 42px;
height: 42px;
font-size: 9px;
}
.rm__fab {
width: 48px;
height: 48px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.rm__item, .rm__fab {
transition: none;
}
}(function () {
var ACTIONS = ['복사', '붙여넣기', '삭제', '실행 취소', '다시 실행', '저장'];
var ARC_SPAN_DEG = 200; // 부채꼴이 펼쳐지는 총 각도
var RADIUS = 96; // 중심에서 항목까지 거리(px)
var LOOP_MS = 2000; // 블로그설정.json 데모.초 와 같다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('rm');
var field = document.getElementById('rmField');
var list = document.getElementById('rmItems');
var fab = document.getElementById('rmFab');
var status = document.getElementById('rmStatus');
var isDemo = true;
var open = false;
// 계산 겹: 극좌표 → 데카르트 좌표. 항목 i 는 위쪽(-90도)을 중심으로 ARC_SPAN_DEG 를 균등 분할한
// 각도에서 RADIUS 만큼 떨어진 자리를 차지한다. 이 좌표는 열림 여부와 무관하게 한 번만 계산한다.
var items = ACTIONS.map(function (label, i) {
var li = document.createElement('li');
li.className = 'rm__item';
li.setAttribute('role', 'none');
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'rm__itemBtn';
btn.setAttribute('role', 'menuitem');
btn.tabIndex = -1;
btn.textContent = label;
var angleDeg = -90 - ARC_SPAN_DEG / 2 + i * (ARC_SPAN_DEG / (ACTIONS.length - 1));
var rad = angleDeg * Math.PI / 180;
var x = Math.round(Math.cos(rad) * RADIUS);
var y = Math.round(Math.sin(rad) * RADIUS);
li.style.setProperty('--x', x);
li.style.setProperty('--y', y);
li.style.setProperty('--stagger', i);
li.appendChild(btn);
list.appendChild(li);
btn.addEventListener('click', function () {
human();
status.textContent = '선택: ' + label;
setOpen(false);
});
return { li: li, btn: btn, label: label };
});
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
function setOpen(next) {
open = next;
root.classList.toggle('rm--open', open);
fab.setAttribute('aria-expanded', String(open));
list.setAttribute('aria-hidden', String(!open));
items.forEach(function (it) { it.btn.tabIndex = open ? 0 : -1; });
}
fab.addEventListener('click', function () { human(); setOpen(!open); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && open) { human(); setOpen(false); fab.focus({ preventScroll: true }); }
});
// 자동 루프: 열림 구간(0.12~0.62)에서 항목마다 --stagger 만큼 늦게 열리고, 그 뒤 닫힌다
function loop(ts) {
if (isDemo && !reduce) {
var phase = (ts % LOOP_MS) / LOOP_MS;
var shouldOpen = phase > 0.12 && phase < 0.62;
if (shouldOpen !== open) { open = shouldOpen; root.classList.toggle('rm--open', open); }
} else if (reduce && isDemo) {
root.classList.add('rm--open');
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// RadialMenu — vanilla 판(run/374/demo/01-arc-radial-menu/index.html) 의 React 포트.
// 계산 겹은 각 항목의 극좌표 배치(각도→x,y)에 있다 — 부채꼴로 펼친 각도를 라디안으로 바꿔 cos/sin 으로 좌표를 구한다.
import { useEffect, useRef, useState } from "react";
import styles from "./RadialMenu.module.scss";
type Box<T> = { current: T };
const ACTIONS = ["복사", "붙여넣기", "삭제", "실행 취소", "다시 실행", "저장"];
const ARC_SPAN_DEG = 200;
const RADIUS = 96;
const LOOP_MS = 2000;
export default function RadialMenu() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLUListElement>;
const fabRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
const [status, setStatus] = useState("선택: 없음");
const [open, setOpenState] = useState(false);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const list = listRef.current;
const fab = fabRef.current;
if (!root || !list || !fab) return;
let isDemo = true;
let open = false;
const buttons: HTMLButtonElement[] = Array.prototype.slice.call(list.querySelectorAll("button"));
const items: HTMLLIElement[] = Array.prototype.slice.call(list.querySelectorAll("li"));
items.forEach(function (li, i) {
const angleDeg = -90 - ARC_SPAN_DEG / 2 + i * (ARC_SPAN_DEG / (ACTIONS.length - 1));
const rad = (angleDeg * Math.PI) / 180;
const x = Math.round(Math.cos(rad) * RADIUS);
const y = Math.round(Math.sin(rad) * RADIUS);
li.style.setProperty("--x", String(x));
li.style.setProperty("--y", String(y));
li.style.setProperty("--stagger", String(i));
});
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function setOpen(next: boolean) {
open = next;
root!.classList.toggle(styles.rmOpen, open);
fab!.setAttribute("aria-expanded", String(open));
list!.setAttribute("aria-hidden", String(!open));
buttons.forEach(function (b) { b.tabIndex = open ? 0 : -1; });
setOpenState(open);
}
function onFabClick() { human(); setOpen(!open); }
fab.addEventListener("click", onFabClick);
const onItemClicks: Array<() => void> = [];
buttons.forEach(function (btn, i) {
const handler = function () {
human();
setStatus("선택: " + ACTIONS[i]);
setOpen(false);
};
onItemClicks.push(handler);
btn.addEventListener("click", handler);
});
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && open) { human(); setOpen(false); fab!.focus({ preventScroll: true }); }
}
document.addEventListener("keydown", onKeydown);
function loop(ts: number) {
if (isDemo && !reduce) {
const phase = (ts % LOOP_MS) / LOOP_MS;
const shouldOpen = phase > 0.12 && phase < 0.62;
if (shouldOpen !== open) { open = shouldOpen; root!.classList.toggle(styles.rmOpen, open); setOpenState(open); }
} else if (reduce && isDemo) {
root!.classList.add(styles.rmOpen);
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
fab.removeEventListener("click", onFabClick);
buttons.forEach(function (btn, i) { btn.removeEventListener("click", onItemClicks[i]); });
document.removeEventListener("keydown", onKeydown);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.rm + " " + styles.isDemo} ref={rootRef}>
<div className={styles.field}>
<ul className={styles.items} ref={listRef} role="menu" aria-hidden="true">
{ACTIONS.map(function (label) {
return (
<li key={label} className={styles.item} role="none">
<button className={styles.itemBtn} type="button" role="menuitem" tabIndex={-1}>{label}</button>
</li>
);
})}
</ul>
<button className={styles.fab} ref={fabRef} type="button" aria-haspopup="true" aria-expanded={open}>메뉴 열기</button>
</div>
<p className={styles.status}>{status}</p>
</div>
</main>
);
}01 · 방사형 메뉴 — 중심에서 부채꼴로 펼쳐지는 원형 메뉴 — 측정 누적면적 11.25% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02장르 선택 휠 — 카테고리를 원판처럼 돌려 고르는 방식
드래그로 쌓인 회전각을 ((-rotation % 360) + 360) % 360 나머지로 정규화하고 STEP_DEG(=360/7) 단위로 나눠 지금 중앙을 지나는 인덱스를 구합니다. 각 장르의 색은 i * HUE_STEP_DEG 를 --hue 로 넘겨 색상환 각도로 그대로 매핑되고, 손을 떼면 가장 가까운 STEP_DEG 배수로 붙어 한 장르가 언제나 정중앙에 옵니다.
function apply() {
ring.style.setProperty('--rotation', rotation);
var idx = activeIndex();
lis.forEach(function (li, i) { li.classList.toggle('gw__slot--active', i === idx); });
picked.textContent = '선택: ' + GENRES[idx];
}
wheel.addEventListener('pointerdown', function (e) {
human();
dragging = true;
startX = e.clientX;
startRotation = rotation;
wheel.setPointerCapture(e.pointerId);
});
wheel.addEventListener('pointermove', function (e) {
if (!dragging) return;
rotation = startRotation + (e.clientX - startX) * 0.6;
apply();
});
wheel.addEventListener('pointerup', function () {
dragging = false;
// 계산 겹: 놓은 각도를 가장 가까운 STEP_DEG 배수로 붙여 항상 한 장르가 정중앙에 오게 한다.
rotation = Math.round(rotation / STEP_DEG) * STEP_DEG;
apply();
});

<main class="stage">
<div class="gw is-demo" id="gw">
<div class="gw__wheel" id="gwWheel" tabindex="0" role="listbox" aria-label="장르 선택 휠">
<ul class="gw__ring" id="gwRing"></ul>
</div>
<div class="gw__pointer" aria-hidden="true"></div>
<p class="gw__picked" id="gwPicked">선택: 팝</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 장르 선택 휠 — 계산 겹은 index.html 의 두 곳: ① 회전각을 STEP_DEG(360/7)로 나눈 나머지로
// "지금 중앙을 지나는 인덱스"를 구하는 나머지 연산 ② 인덱스를 색상환 각도로 그대로 쓰는 색상 각도 매핑(--hue).
// 슬롯 하나의 배치 각도(51.4286deg = 360/7)는 항목 수가 고정이라 SCSS 에 그대로 적는다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-yellow);
.gw { position: relative; display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.gw__wheel {
position: relative;
width: 220px;
height: 220px;
border-radius: 50%;
background: rgba($subject-ink, .06);
touch-action: none;
cursor: grab;
}
.gw__wheel:active { cursor: grabbing; }
.gw__wheel:focus-visible { outline: 2px solid $subject-ink; outline-offset: 3px; }
.gw__ring {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
transform: rotate(calc(var(--rotation, 0) * 1deg));
}
.gw__slot {
position: absolute;
left: 50%;
top: 50%;
width: 60px;
height: 26px;
margin-left: -30px;
margin-top: -13px;
display: flex;
align-items: center;
justify-content: center;
border-radius: $r-pill;
font-weight: 700;
font-size: 10.5px;
color: $subject-ink;
background: hsl(calc(var(--hue, 0) * 1deg) 46% 80%);
transform: rotate(calc(var(--i, 0) * 51.4286deg)) translateY(-86px) rotate(calc(var(--i, 0) * -51.4286deg));
transition: transform $duration $easing, background-color $duration $easing, box-shadow $duration $easing;
}
.gw__slot--active {
background: hsl(calc(var(--hue, 0) * 1deg) 46% 64%);
box-shadow: $shadow-press;
transform: rotate(calc(var(--i, 0) * 51.4286deg)) translateY(-86px) rotate(calc(var(--i, 0) * -51.4286deg)) scale(1.2);
}
.gw__pointer {
position: absolute;
top: -2px;
left: 50%;
width: 0;
height: 0;
transform: translateX(-50%);
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 10px solid $subject-ink;
}
.gw__picked {
margin: 0;
color: $subject-ink;
font-weight: 800;
font-size: 12px;
}
@media (max-width: 340px) {
.gw { gap: $sp-1; }
.gw__wheel { width: 148px; height: 148px; }
.gw__slot { width: 44px; height: 20px; margin-left: -22px; margin-top: -10px; font-size: 8.5px;
transform: rotate(calc(var(--i, 0) * 51.4286deg)) translateY(-58px) rotate(calc(var(--i, 0) * -51.4286deg)); }
.gw__slot--active { transform: rotate(calc(var(--i, 0) * 51.4286deg)) translateY(-58px) rotate(calc(var(--i, 0) * -51.4286deg)) scale(1.2); }
}
@media (prefers-reduced-motion: reduce) {
.gw__ring, .gw__slot { transition: none; }
}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);
}
.gw {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.gw__wheel {
position: relative;
width: 220px;
height: 220px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.06);
touch-action: none;
cursor: grab;
}
.gw__wheel:active {
cursor: grabbing;
}
.gw__wheel:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.gw__ring {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
transform: rotate(calc(var(--rotation, 0) * 1deg));
}
.gw__slot {
position: absolute;
left: 50%;
top: 50%;
width: 60px;
height: 26px;
margin-left: -30px;
margin-top: -13px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
font-weight: 700;
font-size: 10.5px;
color: #17141a;
background: hsl(calc(var(--hue, 0) * 1deg), 46%, 80%);
transform: rotate(calc(var(--i, 0) * 51.4286deg)) translateY(-86px) rotate(calc(var(--i, 0) * -51.4286deg));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gw__slot--active {
background: hsl(calc(var(--hue, 0) * 1deg), 46%, 64%);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: rotate(calc(var(--i, 0) * 51.4286deg)) translateY(-86px) rotate(calc(var(--i, 0) * -51.4286deg)) scale(1.2);
}
.gw__pointer {
position: absolute;
top: -2px;
left: 50%;
width: 0;
height: 0;
transform: translateX(-50%);
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-top: 10px solid #17141a;
}
.gw__picked {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: 12px;
}
@media (max-width: 340px) {
.gw {
gap: 4px;
}
.gw__wheel {
width: 148px;
height: 148px;
}
.gw__slot {
width: 44px;
height: 20px;
margin-left: -22px;
margin-top: -10px;
font-size: 8.5px;
transform: rotate(calc(var(--i, 0) * 51.4286deg)) translateY(-58px) rotate(calc(var(--i, 0) * -51.4286deg));
}
.gw__slot--active {
transform: rotate(calc(var(--i, 0) * 51.4286deg)) translateY(-58px) rotate(calc(var(--i, 0) * -51.4286deg)) scale(1.2);
}
}
@media (prefers-reduced-motion: reduce) {
.gw__ring, .gw__slot {
transition: none;
}
}(function () {
var GENRES = ['팝', '재즈', '로파이', '록', '클래식', '힙합', '전자'];
var N = GENRES.length;
var STEP_DEG = 360 / N; // 장르 하나가 차지하는 각도
var HUE_STEP_DEG = 360 / N; // 색상 각도 매핑 — 장르 인덱스를 색상환 각도로 그대로 쓴다
var LOOP_MS = 2000;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('gw');
var wheel = document.getElementById('gwWheel');
var ring = document.getElementById('gwRing');
var picked = document.getElementById('gwPicked');
var isDemo = true;
var rotation = 0; // 원판이 돌아간 각도
var dragging = false;
var startX = 0, startRotation = 0;
var lis = GENRES.map(function (g, i) {
var li = document.createElement('li');
li.className = 'gw__slot';
li.textContent = g;
li.style.setProperty('--i', i);
// 계산 겹: 색상 각도 매핑. 채도·명도는 토큰 범위 안에서 고정하고 색상각만 인덱스로 돈다.
li.style.setProperty('--hue', i * HUE_STEP_DEG);
ring.appendChild(li);
return li;
});
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 회전각을 STEP_DEG 로 나눈 나머지로 "지금 중앙을 지나는 인덱스"를 구한다.
function activeIndex() {
var norm = ((-rotation % 360) + 360) % 360;
return Math.round(norm / STEP_DEG) % N;
}
function apply() {
ring.style.setProperty('--rotation', rotation);
var idx = activeIndex();
lis.forEach(function (li, i) { li.classList.toggle('gw__slot--active', i === idx); });
picked.textContent = '선택: ' + GENRES[idx];
}
wheel.addEventListener('pointerdown', function (e) {
human();
dragging = true;
startX = e.clientX;
startRotation = rotation;
wheel.setPointerCapture(e.pointerId);
});
wheel.addEventListener('pointermove', function (e) {
if (!dragging) return;
rotation = startRotation + (e.clientX - startX) * 0.6;
apply();
});
wheel.addEventListener('pointerup', function () {
dragging = false;
// 계산 겹: 놓은 각도를 가장 가까운 STEP_DEG 배수로 붙여 항상 한 장르가 정중앙에 오게 한다.
rotation = Math.round(rotation / STEP_DEG) * STEP_DEG;
apply();
});
wheel.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { human(); rotation -= STEP_DEG; apply(); e.preventDefault(); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { human(); rotation += STEP_DEG; apply(); e.preventDefault(); }
});
function loop(ts) {
if (isDemo) {
if (!reduce) {
var phase = (ts % LOOP_MS) / LOOP_MS;
rotation = -phase * 360 * 2; // 두 바퀴를 도는 연속 회전
} else {
rotation = 0;
}
apply();
}
requestAnimationFrame(loop);
}
apply();
requestAnimationFrame(loop);
})();// GenreWheel — vanilla 판(run/374/demo/02-arc-genre-wheel/index.html) 의 React 포트.
// 계산 겹 ① 회전각을 STEP_DEG(360/7) 로 나눈 나머지로 "지금 중앙을 지나는 인덱스"를 구한다
// ② 인덱스를 색상환 각도로 그대로 쓰는 색상 각도 매핑(hsl, hex 리터럴 없음).
import { useEffect, useRef, useState } from "react";
import styles from "./GenreWheel.module.scss";
type Box<T> = { current: T };
const GENRES = ["팝", "재즈", "로파이", "록", "클래식", "힙합", "전자"];
const N = GENRES.length;
const STEP_DEG = 360 / N;
const HUE_STEP_DEG = 360 / N;
const LOOP_MS = 2000;
export default function GenreWheel() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const wheelRef = useRef(null) as unknown as Box<HTMLDivElement>;
const ringRef = useRef(null) as unknown as Box<HTMLUListElement>;
const rafRef = useRef(0) as unknown as Box<number>;
const [picked, setPicked] = useState(GENRES[0]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const wheel = wheelRef.current;
const ring = ringRef.current;
if (!root || !wheel || !ring) return;
const lis: HTMLLIElement[] = Array.prototype.slice.call(ring.querySelectorAll("li"));
lis.forEach(function (li, i) { li.style.setProperty("--hue", String(i * HUE_STEP_DEG)); });
let isDemo = true;
let rotation = 0;
let dragging = false;
let startX = 0;
let startRotation = 0;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function activeIndex() {
const norm = (((-rotation) % 360) + 360) % 360;
return Math.round(norm / STEP_DEG) % N;
}
function apply() {
ring!.style.setProperty("--rotation", String(rotation));
const idx = activeIndex();
lis.forEach(function (li, i) { li.classList.toggle(styles.slotActive, i === idx); });
setPicked(GENRES[idx]);
}
function onPointerDown(e: PointerEvent) {
human();
dragging = true;
startX = e.clientX;
startRotation = rotation;
wheel!.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
rotation = startRotation + (e.clientX - startX) * 0.6;
apply();
}
function onPointerUp() {
dragging = false;
rotation = Math.round(rotation / STEP_DEG) * STEP_DEG;
apply();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "ArrowRight" || e.key === "ArrowDown") { human(); rotation -= STEP_DEG; apply(); e.preventDefault(); }
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { human(); rotation += STEP_DEG; apply(); e.preventDefault(); }
}
wheel.addEventListener("pointerdown", onPointerDown);
wheel.addEventListener("pointermove", onPointerMove);
wheel.addEventListener("pointerup", onPointerUp);
wheel.addEventListener("keydown", onKeydown);
function loop(ts: number) {
if (isDemo) {
if (!reduce) {
const phase = (ts % LOOP_MS) / LOOP_MS;
rotation = -phase * 360 * 2;
} else {
rotation = 0;
}
apply();
}
rafRef.current = requestAnimationFrame(loop);
}
apply();
rafRef.current = requestAnimationFrame(loop);
return () => {
wheel.removeEventListener("pointerdown", onPointerDown);
wheel.removeEventListener("pointermove", onPointerMove);
wheel.removeEventListener("pointerup", onPointerUp);
wheel.removeEventListener("keydown", onKeydown);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.gw + " " + styles.isDemo} ref={rootRef}>
<div className={styles.wheel} ref={wheelRef} tabIndex={0} role="listbox" aria-label="장르 선택 휠">
<ul className={styles.ring} ref={ringRef}>
{GENRES.map(function (g, i) {
return <li key={g} className={styles.slot} style={{ ["--i" as string]: i }}>{g}</li>;
})}
</ul>
</div>
<div className={styles.pointer} aria-hidden="true" />
<p className={styles.picked}>선택: {picked}</p>
</div>
</main>
);
}02 · 장르 선택 휠 — 카테고리를 원판처럼 돌려 고르는 방식 — 측정 누적면적 23.781% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03구간 호버 링 — 마우스가 지나는 구간만 도드라지는 원형 띠
포인터 좌표를 Math.atan2 로 링 중심 기준 각도로 바꾸고 12시를 0 도로 맞춘 뒤, segmentIndexAt() 의 나머지 연산으로 360/N 폭의 어느 구간인지 판정합니다. 클릭이나 드래그 없이 포인터가 지나가는 것만으로 반응하고 pointerleave 로 해제됩니다.
// 계산 겹: 포인터 좌표를 링 중심 기준 각도로 바꾼 뒤(atan2), 어느 구간(360/N 폭)에 속하는지 판정한다.
function angleAt(clientX, clientY) {
var r = ring.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
var deg = Math.atan2(clientY - cy, clientX - cx) * 180 / Math.PI + 90; // 12시를 0도로
return ((deg % 360) + 360) % 360;
}
function segmentIndexAt(deg) {
return Math.floor(deg / (360 / N)) % N;
}

<main class="stage">
<div class="sh is-demo" id="sh">
<div class="sh__ring" id="shRing">
<div class="sh__center">
<b class="sh__pct" id="shPct">0%</b>
<span class="sh__label" id="shLabel">CPU</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 구간 호버 링 — 계산 겹은 index.html 의 atan2 각도 계산과 360/N 구간 판정(segmentIndexAt)에 있다.
// 여기서는 구간마다 conic-gradient 로 자기 몫의 호만 칠하고 mask-image 로 도넛 모양을 낸 뒤,
// 활성 구간만 scale·filter 로 두께·밝기를 바꾼다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue-light;
@include stage($stage-orange);
.sh { display: flex; align-items: center; justify-content: center; }
.sh__ring {
position: relative;
width: 200px;
height: 200px;
display: grid;
place-items: center;
touch-action: none;
}
.sh__seg {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(from calc(var(--start, 0) * 1deg),
$color 0deg calc(var(--span, 0) * 1deg), transparent calc(var(--span, 0) * 1deg) 360deg);
mask-image: radial-gradient(circle, transparent 58%, black 60%, black 84%, transparent 86%);
-webkit-mask-image: radial-gradient(circle, transparent 58%, black 60%, black 84%, transparent 86%);
transform: scale(1);
filter: brightness(.82);
transition: transform $duration $easing, filter $duration $easing;
}
.sh__seg--active {
transform: scale(1.08);
filter: brightness(1.3);
}
.sh__center {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
pointer-events: none;
}
.sh__pct { font-size: 26px; font-weight: 800; color: $subject-ink; line-height: 1; }
.sh__label { font-size: 11px; font-weight: 700; color: rgba($subject-ink, .68); letter-spacing: .04em; }
@media (max-width: 340px) {
.sh__ring { width: 150px; height: 150px; }
.sh__pct { font-size: 20px; }
.sh__label { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sh__seg { 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);
}
.sh {
display: flex;
align-items: center;
justify-content: center;
}
.sh__ring {
position: relative;
width: 200px;
height: 200px;
display: grid;
place-items: center;
touch-action: none;
}
.sh__seg {
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(from calc(var(--start, 0) * 1deg), #5b8cf8 0deg calc(var(--span, 0) * 1deg), transparent calc(var(--span, 0) * 1deg) 360deg);
mask-image: radial-gradient(circle, transparent 58%, black 60%, black 84%, transparent 86%);
-webkit-mask-image: radial-gradient(circle, transparent 58%, black 60%, black 84%, transparent 86%);
transform: scale(1);
filter: brightness(0.82);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), filter 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sh__seg--active {
transform: scale(1.08);
filter: brightness(1.3);
}
.sh__center {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
pointer-events: none;
}
.sh__pct {
font-size: 26px;
font-weight: 800;
color: #17141a;
line-height: 1;
}
.sh__label {
font-size: 11px;
font-weight: 700;
color: rgba(23, 20, 26, 0.68);
letter-spacing: 0.04em;
}
@media (max-width: 340px) {
.sh__ring {
width: 150px;
height: 150px;
}
.sh__pct {
font-size: 20px;
}
.sh__label {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sh__seg {
transition: none;
}
}(function () {
var SEGMENTS = [
{ label: 'CPU', pct: 42 },
{ label: 'GPU', pct: 68 },
{ label: 'RAM', pct: 55 },
{ label: '네트워크', pct: 27 },
{ label: '디스크', pct: 81 },
{ label: '전원', pct: 34 }
];
var N = SEGMENTS.length;
var GAP_DEG = 3; // 구간 사이 틈
var SEG_DEG = 360 / N - GAP_DEG; // 구간 하나의 실제 폭
var LOOP_MS = 2000;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('sh');
var ring = document.getElementById('shRing');
var pctEl = document.getElementById('shPct');
var labelEl = document.getElementById('shLabel');
var isDemo = true;
var segs = SEGMENTS.map(function (s, i) {
var start = i * 360 / N;
var el = document.createElement('div');
el.className = 'sh__seg';
el.style.setProperty('--start', start);
el.style.setProperty('--span', SEG_DEG);
el.setAttribute('aria-hidden', 'true');
ring.appendChild(el);
return { el: el, start: start, data: s };
});
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 포인터 좌표를 링 중심 기준 각도로 바꾼 뒤(atan2), 어느 구간(360/N 폭)에 속하는지 판정한다.
function angleAt(clientX, clientY) {
var r = ring.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
var deg = Math.atan2(clientY - cy, clientX - cx) * 180 / Math.PI + 90; // 12시를 0도로
return ((deg % 360) + 360) % 360;
}
function segmentIndexAt(deg) {
return Math.floor(deg / (360 / N)) % N;
}
function setActive(idx) {
segs.forEach(function (s, i) { s.el.classList.toggle('sh__seg--active', i === idx); });
var d = SEGMENTS[idx];
pctEl.textContent = d.pct + '%';
labelEl.textContent = d.label;
}
ring.addEventListener('pointermove', function (e) {
human();
setActive(segmentIndexAt(angleAt(e.clientX, e.clientY)));
});
ring.addEventListener('pointerleave', function () { human(); });
// 자동 루프: 가상 포인터가 원을 한 바퀴 도는 것처럼 각도를 연속으로 흘려 같은 판정 함수를 태운다
function loop(ts) {
if (isDemo) {
if (!reduce) {
var phase = (ts % LOOP_MS) / LOOP_MS;
setActive(segmentIndexAt(phase * 360));
} else {
setActive(0);
}
}
requestAnimationFrame(loop);
}
setActive(0);
requestAnimationFrame(loop);
})();// SegmentHoverRing — vanilla 판(run/374/demo/03-arc-segment-hover/index.html) 의 React 포트.
// 계산 겹: 포인터 좌표를 링 중심 기준 atan2 각도로 바꾼 뒤(12시를 0도로), 360/N 폭 구간 중 어디에 속하는지 판정한다.
import { useEffect, useRef, useState } from "react";
import styles from "./SegmentHoverRing.module.scss";
type Box<T> = { current: T };
type Segment = { label: string; pct: number };
const SEGMENTS: Segment[] = [
{ label: "CPU", pct: 42 },
{ label: "GPU", pct: 68 },
{ label: "RAM", pct: 55 },
{ label: "네트워크", pct: 27 },
{ label: "디스크", pct: 81 },
{ label: "전원", pct: 34 },
];
const N = SEGMENTS.length;
const GAP_DEG = 3;
const SEG_DEG = 360 / N - GAP_DEG;
const LOOP_MS = 2000;
export default function SegmentHoverRing() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const ringRef = useRef(null) as unknown as Box<HTMLDivElement>;
const rafRef = useRef(0) as unknown as Box<number>;
const [active, setActiveState] = useState(0);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const ring = ringRef.current;
if (!root || !ring) return;
const segEls: HTMLDivElement[] = Array.prototype.slice.call(ring.querySelectorAll("[data-seg]"));
segEls.forEach(function (el, i) {
el.style.setProperty("--start", String((i * 360) / N));
el.style.setProperty("--span", String(SEG_DEG));
});
let isDemo = true;
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;
const deg = (Math.atan2(clientY - cy, clientX - cx) * 180) / Math.PI + 90;
return ((deg % 360) + 360) % 360;
}
function segmentIndexAt(deg: number) {
return Math.floor(deg / (360 / N)) % N;
}
function setActive(idx: number) {
segEls.forEach(function (el, i) { el.classList.toggle(styles.segActive, i === idx); });
setActiveState(idx);
}
function onMove(e: PointerEvent) { human(); setActive(segmentIndexAt(angleAt(e.clientX, e.clientY))); }
function onLeave() { human(); }
ring.addEventListener("pointermove", onMove);
ring.addEventListener("pointerleave", onLeave);
function loop(ts: number) {
if (isDemo) {
if (!reduce) {
const phase = (ts % LOOP_MS) / LOOP_MS;
setActive(segmentIndexAt(phase * 360));
} else {
setActive(0);
}
}
rafRef.current = requestAnimationFrame(loop);
}
setActive(0);
rafRef.current = requestAnimationFrame(loop);
return () => {
ring.removeEventListener("pointermove", onMove);
ring.removeEventListener("pointerleave", onLeave);
cancelAnimationFrame(rafRef.current);
};
}, []);
const activeData = SEGMENTS[active];
return (
<main className={styles.stage}>
<div className={styles.sh + " " + styles.isDemo} ref={rootRef}>
<div className={styles.ring} ref={ringRef}>
{SEGMENTS.map(function (s, i) {
return <div key={s.label} data-seg className={styles.seg} aria-hidden="true" />;
})}
<div className={styles.center}>
<b className={styles.pct}>{activeData.pct}%</b>
<span className={styles.label}>{activeData.label}</span>
</div>
</div>
</div>
</main>
);
}03 · 구간 호버 링 — 마우스가 지나는 구간만 도드라지는 원형 띠 — 측정 누적면적 11.2% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04드래그 관성 손잡이 — 손을 떼도 서서히 멈추는 회전 손잡이
드래그 중엔 포인터 각도의 변화량이 그대로 프레임당 속도로 기록되고, 손을 떼면 decayStep() 이 매 프레임 velocity *= DRAG_DECAY(0.92) 로 속도를 줄이며 각도에 계속 더합니다. MIN_VELOCITY(0.15) 아래로 떨어지면 정지로 보는데, 놓은 속도가 세면 셀수록 더 오래 도는 물리감이 여기서 나옵니다.
// 계산 겹: 관성 감쇠. 놓은 순간의 속도가 매 프레임 DRAG_DECAY 배로 줄며 각도에 계속 더해진다.
// reduce 에서는(실사용 포함) 감쇠 구간을 건너뛰고 즉시 멈춘다 — human() 뒤에도 이 분기는 그대로 적용된다.
function decayStep() {
if (dragging) return;
if (reduce) { velocity = 0; return; }
if (Math.abs(velocity) < MIN_VELOCITY) { velocity = 0; return; }
velocity *= DRAG_DECAY;
angle += velocity;
}

<main class="stage">
<div class="dr is-demo" id="dr">
<div class="dr__ring" id="drRing">
<button class="dr__knob" id="drKnob" type="button" aria-label="드래그해서 볼륨 손잡이 돌리기"></button>
</div>
<p class="dr__hint" id="drHint">속도: 0.0 도/프레임</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 드래그 관성 손잡이 — 계산 겹은 index.html 의 감쇠 함수(decayStep)에 있다: 놓는 순간의 속도가
// 매 프레임 DRAG_DECAY 배씩 줄며 각도에 계속 더해진다. 여기는 손잡이·바늘·눈금만 그린다.
$duration: $dur-instant;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.dr { display: flex; flex-direction: column; align-items: center; gap: $sp-4; }
.dr__ring {
position: relative;
width: 180px;
height: 180px;
border-radius: 50%;
background: repeating-conic-gradient(rgba($subject-ink, .12) 0 2deg, transparent 2deg 30deg);
touch-action: none;
}
.dr__knob {
position: absolute;
inset: 24px;
appearance: none;
border: none;
border-radius: 50%;
cursor: grab;
background: radial-gradient(circle at 35% 30%, rgba($subject-cream, .9), $color 70%);
box-shadow: $shadow-raised;
transform: rotate(calc(var(--angle, 0) * 1deg));
}
.dr__knob:active { cursor: grabbing; }
.dr__knob:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 3px; }
.dr__knob::after {
content: "";
position: absolute;
top: 10px;
left: calc(50% - 3px);
width: 6px;
height: 22px;
border-radius: $r-pill;
background: $subject-cream;
}
.dr__hint {
margin: 0;
color: $subject-ink;
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.dr__ring { width: 132px; height: 132px; }
.dr__knob { inset: 16px; }
.dr__knob::after { height: 16px; top: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.dr__knob { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dr {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.dr__ring {
position: relative;
width: 180px;
height: 180px;
border-radius: 50%;
background: repeating-conic-gradient(rgba(23, 20, 26, 0.12) 0 2deg, transparent 2deg 30deg);
touch-action: none;
}
.dr__knob {
position: absolute;
inset: 24px;
appearance: none;
border: none;
border-radius: 50%;
cursor: grab;
background: radial-gradient(circle at 35% 30%, rgba(255, 247, 230, 0.9), #2f6df6 70%);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: rotate(calc(var(--angle, 0) * 1deg));
}
.dr__knob:active {
cursor: grabbing;
}
.dr__knob:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 3px;
}
.dr__knob::after {
content: "";
position: absolute;
top: 10px;
left: calc(50% - 3px);
width: 6px;
height: 22px;
border-radius: 999px;
background: #fff7e6;
}
.dr__hint {
margin: 0;
color: #17141a;
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.dr__ring {
width: 132px;
height: 132px;
}
.dr__knob {
inset: 16px;
}
.dr__knob::after {
height: 16px;
top: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.dr__knob {
transition: none;
}
}(function () {
var DRAG_DECAY = 0.92; // 손을 뗀 뒤 매 프레임 속도에 곱하는 감쇠율 — 1에 가까울수록 오래 돈다
var MIN_VELOCITY = 0.15; // 이 아래로 떨어지면 정지로 본다(도/프레임)
var LOOP_MS = 2000;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('dr');
var ring = document.getElementById('drRing');
var knob = document.getElementById('drKnob');
var hint = document.getElementById('drHint');
var isDemo = true;
var angle = 0;
var velocity = 0; // 도/프레임 — 드래그 중엔 포인터 이동에서, 놓은 뒤엔 감쇠식에서 갱신
var dragging = false;
var lastAngle = 0;
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;
}
function apply() {
knob.style.setProperty('--angle', angle);
hint.textContent = '속도: ' + Math.abs(velocity).toFixed(1) + ' 도/프레임';
}
knob.addEventListener('pointerdown', function (e) {
human();
dragging = true;
velocity = 0;
lastAngle = angleAt(e.clientX, e.clientY);
knob.setPointerCapture(e.pointerId);
});
knob.addEventListener('pointermove', function (e) {
if (!dragging) return;
var a = angleAt(e.clientX, e.clientY);
var delta = a - lastAngle;
if (delta > 180) delta -= 360; else if (delta < -180) delta += 360;
angle += delta;
velocity = reduce ? 0 : delta; // 이동량 자체를 프레임당 속도로 기록
lastAngle = a;
apply();
});
knob.addEventListener('pointerup', function () { dragging = false; });
knob.addEventListener('pointercancel', function () { dragging = false; });
// 계산 겹: 관성 감쇠. 놓은 순간의 속도가 매 프레임 DRAG_DECAY 배로 줄며 각도에 계속 더해진다.
// reduce 에서는(실사용 포함) 감쇠 구간을 건너뛰고 즉시 멈춘다 — human() 뒤에도 이 분기는 그대로 적용된다.
function decayStep() {
if (dragging) return;
if (reduce) { velocity = 0; return; }
if (Math.abs(velocity) < MIN_VELOCITY) { velocity = 0; return; }
velocity *= DRAG_DECAY;
angle += velocity;
}
// 자동 루프: 최고 속도로 "튕긴" 상태를 반복해 감쇠 곡선 전체를 계속 보여준다
var demoVelocity = 26;
function loop(ts) {
if (isDemo) {
if (!reduce) {
var phase = (ts % LOOP_MS) / LOOP_MS;
if (phase < 0.02) { velocity = demoVelocity; }
decayStep();
} else {
angle = 0; velocity = 0;
}
apply();
} else {
decayStep();
apply();
}
requestAnimationFrame(loop);
}
apply();
requestAnimationFrame(loop);
})();// DragRotateKnob — vanilla 판(run/374/demo/04-arc-drag-rotate/index.html) 의 React 포트.
// 계산 겹: 관성 감쇠(decayStep) — 놓은 순간의 속도가 매 프레임 DRAG_DECAY 배로 줄며 각도에 계속 더해진다.
// reduce 체크는 decayStep 안에 있어 isDemo 분기 밖에서도(실사용에서도) 그대로 적용된다.
import { useEffect, useRef, useState } from "react";
import styles from "./DragRotateKnob.module.scss";
type Box<T> = { current: T };
const DRAG_DECAY = 0.92;
const MIN_VELOCITY = 0.15;
const LOOP_MS = 2000;
export default function DragRotateKnob() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const ringRef = useRef(null) as unknown as Box<HTMLDivElement>;
const knobRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
const [speedLabel, setSpeedLabel] = useState("속도: 0.0 도/프레임");
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const ring = ringRef.current;
const knob = knobRef.current;
if (!root || !ring || !knob) return;
let isDemo = true;
let angle = 0;
let velocity = 0;
let dragging = false;
let lastAngle = 0;
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;
}
function apply() {
knob!.style.setProperty("--angle", String(angle));
setSpeedLabel("속도: " + Math.abs(velocity).toFixed(1) + " 도/프레임");
}
function onDown(e: PointerEvent) {
human();
dragging = true;
velocity = 0;
lastAngle = angleAt(e.clientX, e.clientY);
knob!.setPointerCapture(e.pointerId);
}
function onMove(e: PointerEvent) {
if (!dragging) return;
const a = angleAt(e.clientX, e.clientY);
let delta = a - lastAngle;
if (delta > 180) delta -= 360; else if (delta < -180) delta += 360;
angle += delta;
velocity = reduce ? 0 : delta;
lastAngle = a;
apply();
}
function onUp() { dragging = false; }
knob.addEventListener("pointerdown", onDown);
knob.addEventListener("pointermove", onMove);
knob.addEventListener("pointerup", onUp);
knob.addEventListener("pointercancel", onUp);
function decayStep() {
if (dragging) return;
if (reduce) { velocity = 0; return; }
if (Math.abs(velocity) < MIN_VELOCITY) { velocity = 0; return; }
velocity *= DRAG_DECAY;
angle += velocity;
}
const demoVelocity = 26;
function loop(ts: number) {
if (isDemo) {
if (!reduce) {
const phase = (ts % LOOP_MS) / LOOP_MS;
if (phase < 0.02) { velocity = demoVelocity; }
decayStep();
} else {
angle = 0; velocity = 0;
}
apply();
} else {
decayStep();
apply();
}
rafRef.current = requestAnimationFrame(loop);
}
apply();
rafRef.current = requestAnimationFrame(loop);
return () => {
knob.removeEventListener("pointerdown", onDown);
knob.removeEventListener("pointermove", onMove);
knob.removeEventListener("pointerup", onUp);
knob.removeEventListener("pointercancel", onUp);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.dr + " " + styles.isDemo} ref={rootRef}>
<div className={styles.ring} ref={ringRef}>
<button className={styles.knob} ref={knobRef} type="button" aria-label="드래그해서 볼륨 손잡이 돌리기" />
</div>
<p className={styles.hint}>{speedLabel}</p>
</div>
</main>
);
}04 · 드래그 관성 손잡이 — 손을 떼도 서서히 멈추는 회전 손잡이 — 측정 누적면적 17.067% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05스냅 선택 다이얼 — 가까운 눈금으로 튕기듯 정렬되는 방식
손을 뗀 각도와 NOTCH_DEG 간격 눈금 사이의 최단 각거리를 재고, 그 거리가 SNAP_THRESHOLD_DEG(20) 이내면 그 눈금으로 정착을 시작합니다. 정착은 easeOutBack 곡선 — 목표를 살짝 넘겼다 되돌아오는 튕김 — 이 SETTLE_MS(260ms) 동안 이어지는데, 이 부호 반전 곡선은 cubic-bezier 로는 만들 수 없어 자바스크립트로 계산합니다.
// 계산 겹: 놓은 각도와 가장 가까운 눈금(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;
}

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06원형 게이지 바 — 값에 따라 각도로 채워지는 원형 진행률 표시
퍼센트를 각도로 바꾼 뒤 quantizedDeg() 가 GAUGE_STEP_DEG(6) 배수로 끊어 내려 계단형으로 채웁니다. 채워지는 속도는 별개의 shown += (value - shown) * 0.16 보간과 LOOP_MS 가 정하므로, GAUGE_STEP_DEG 를 바꿔도 속도는 그대로입니다 — 이 두 계산의 분리가 이 항목의 핵심입니다. 원형 프로그레스바 css 로 흔히 검색되는 conic-gradient 채움을 값 계산과 함께 쓰고 싶을 때 붙여 넣는 자리입니다.
// 계산 겹: 퍼센트를 각도로 바꾼 뒤 GAUGE_STEP_DEG 배수로 끊어 내림한다.
// 매끈한 360*value/100 대신 계단형으로 보여 "단계형" 게이지가 된다.
function quantizedDeg(v) {
var raw = Math.min(100, Math.max(0, v)) * 3.6;
return Math.floor(raw / GAUGE_STEP_DEG) * GAUGE_STEP_DEG;
}
function apply() {
ring.style.setProperty('--deg', quantizedDeg(shown));
pctEl.textContent = Math.round(shown) + '%';
}

<main class="stage">
<div class="cp is-demo" id="cp">
<div class="cp__ring" id="cpRing" role="img" aria-label="업로드 진행 원형 게이지">
<div class="cp__center">
<p class="cp__pct" id="cpPct">0%</p>
<p class="cp__label">업로드 중</p>
</div>
</div>
<div class="cp__btns">
<button class="cp__btn" id="cpMinus" type="button" aria-label="10% 줄이기">−</button>
<button class="cp__btn" id="cpPlus" type="button" aria-label="10% 늘리기">+</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 단계형 원형 게이지 — 계산 겹은 index.html 의 quantizedDeg(GAUGE_STEP_DEG 배수로 각도 끊어내림)에 있다.
// 여기는 conic-gradient 로 그 각도까지만 색을 칠한다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.cp { display: flex; flex-direction: column; align-items: center; gap: $sp-5; }
.cp__ring {
position: relative;
width: 180px;
height: 180px;
border-radius: 50%;
background: conic-gradient($color 0deg calc(var(--deg, 0) * 1deg), rgba($subject-ink, .14) calc(var(--deg, 0) * 1deg) 360deg);
display: grid;
place-items: center;
}
.cp__ring::before {
content: "";
position: absolute;
inset: 14px;
border-radius: 50%;
background: $subject-cream;
}
.cp__center { position: relative; display: flex; flex-direction: column; align-items: center; gap: 4px; }
.cp__pct { margin: 0; font-size: 30px; font-weight: 800; color: $subject-ink; line-height: 1; font-variant-numeric: tabular-nums; }
.cp__label { margin: 0; font-size: 11px; font-weight: 700; color: rgba($subject-ink, .6); }
.cp__btns { display: flex; gap: $sp-3; }
.cp__btn {
appearance: none;
border: none;
border-radius: $r-control;
width: 36px;
height: 36px;
font-size: 18px;
font-weight: 700;
color: $subject-cream;
background: $color;
cursor: pointer;
transition: transform $duration $easing;
}
.cp__btn:active { transform: scale(.92); }
.cp__btn:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
@media (max-width: 340px) {
.cp { gap: $sp-3; }
.cp__ring { width: 118px; height: 118px; }
.cp__ring::before { inset: 11px; }
.cp__pct { font-size: 20px; }
.cp__btn { width: 32px; height: 32px; font-size: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.cp__btn { transition: none; }
}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);
}
.cp {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.cp__ring {
position: relative;
width: 180px;
height: 180px;
border-radius: 50%;
background: conic-gradient(#0a4fe8 0deg calc(var(--deg, 0) * 1deg), rgba(23, 20, 26, 0.14) calc(var(--deg, 0) * 1deg) 360deg);
display: grid;
place-items: center;
}
.cp__ring::before {
content: "";
position: absolute;
inset: 14px;
border-radius: 50%;
background: #fff7e6;
}
.cp__center {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.cp__pct {
margin: 0;
font-size: 30px;
font-weight: 800;
color: #17141a;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.cp__label {
margin: 0;
font-size: 11px;
font-weight: 700;
color: rgba(23, 20, 26, 0.6);
}
.cp__btns {
display: flex;
gap: 12px;
}
.cp__btn {
appearance: none;
border: none;
border-radius: 12px;
width: 36px;
height: 36px;
font-size: 18px;
font-weight: 700;
color: #fff7e6;
background: #0a4fe8;
cursor: pointer;
transition: transform 300ms ease-out;
}
.cp__btn:active {
transform: scale(0.92);
}
.cp__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cp {
gap: 12px;
}
.cp__ring {
width: 118px;
height: 118px;
}
.cp__ring::before {
inset: 11px;
}
.cp__pct {
font-size: 20px;
}
.cp__btn {
width: 32px;
height: 32px;
font-size: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.cp__btn {
transition: none;
}
}(function () {
var GAUGE_STEP_DEG = 6; // 고리 채움의 각도 해상도 — 값이 이 배수로만 끊겨 채워진다(매끈한 보간이 아니라 계단식)
var STEP_PCT = 10;
var LOOP_MS = 2000;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('cp');
var ring = document.getElementById('cpRing');
var pctEl = document.getElementById('cpPct');
var minusBtn = document.getElementById('cpMinus');
var plusBtn = document.getElementById('cpPlus');
var isDemo = true;
var value = 0; // 0~100, 실제 목표값
var shown = 0; // 화면에 서서히 다가가는 값(서서히 메워지는 연출)
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 퍼센트를 각도로 바꾼 뒤 GAUGE_STEP_DEG 배수로 끊어 내림한다.
// 매끈한 360*value/100 대신 계단형으로 보여 "단계형" 게이지가 된다.
function quantizedDeg(v) {
var raw = Math.min(100, Math.max(0, v)) * 3.6;
return Math.floor(raw / GAUGE_STEP_DEG) * GAUGE_STEP_DEG;
}
function apply() {
ring.style.setProperty('--deg', quantizedDeg(shown));
pctEl.textContent = Math.round(shown) + '%';
}
function setValue(v) { value = Math.min(100, Math.max(0, v)); }
minusBtn.addEventListener('click', function () { human(); setValue(value - STEP_PCT); });
plusBtn.addEventListener('click', function () { human(); setValue(value + STEP_PCT); });
// 자동 루프: 0 → 100 을 서서히 채웠다가 다시 시작한다. 사람 조작과 같은 apply()/quantizedDeg() 를 쓴다.
function loop(ts) {
if (isDemo) {
if (!reduce) {
var phase = (ts % LOOP_MS) / LOOP_MS;
value = phase < 0.82 ? Math.min(100, (phase / 0.82) * 100) : 100;
} else {
value = 62;
}
}
shown += (value - shown) * (reduce ? 1 : 0.16);
if (Math.abs(value - shown) < 0.05) shown = value;
apply();
requestAnimationFrame(loop);
}
apply();
requestAnimationFrame(loop);
})();// ConicProgressGauge — vanilla 판(run/374/demo/06-arc-conic-progress/index.html) 의 React 포트.
// 계산 겹: quantizedDeg — 퍼센트를 각도로 바꾼 뒤 GAUGE_STEP_DEG 배수로 끊어 내려 매끈한 보간 대신
// 계단형으로 채운다.
import { useEffect, useRef, useState } from "react";
import styles from "./ConicProgressGauge.module.scss";
type Box<T> = { current: T };
const GAUGE_STEP_DEG = 6;
const STEP_PCT = 10;
const LOOP_MS = 2000;
export default function ConicProgressGauge() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const ringRef = useRef(null) as unknown as Box<HTMLDivElement>;
const rafRef = useRef(0) as unknown as Box<number>;
const [pct, setPct] = useState(0);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const ring = ringRef.current;
if (!root || !ring) return;
let isDemo = true;
let value = 0;
let shown = 0;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function quantizedDeg(v: number) {
const raw = Math.min(100, Math.max(0, v)) * 3.6;
return Math.floor(raw / GAUGE_STEP_DEG) * GAUGE_STEP_DEG;
}
function apply() {
ring!.style.setProperty("--deg", String(quantizedDeg(shown)));
setPct(Math.round(shown));
}
function setValue(v: number) { value = Math.min(100, Math.max(0, v)); }
function onMinus() { human(); setValue(value - STEP_PCT); }
function onPlus() { human(); setValue(value + STEP_PCT); }
const minusBtn = root!.querySelector<HTMLButtonElement>("[data-minus]");
const plusBtn = root!.querySelector<HTMLButtonElement>("[data-plus]");
minusBtn?.addEventListener("click", onMinus);
plusBtn?.addEventListener("click", onPlus);
function loop(ts: number) {
if (isDemo) {
if (!reduce) {
const phase = (ts % LOOP_MS) / LOOP_MS;
value = phase < 0.82 ? Math.min(100, (phase / 0.82) * 100) : 100;
} else {
value = 62;
}
}
shown += (value - shown) * (reduce ? 1 : 0.16);
if (Math.abs(value - shown) < 0.05) shown = value;
apply();
rafRef.current = requestAnimationFrame(loop);
}
apply();
rafRef.current = requestAnimationFrame(loop);
return () => {
minusBtn?.removeEventListener("click", onMinus);
plusBtn?.removeEventListener("click", onPlus);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cp + " " + styles.isDemo} ref={rootRef}>
<div className={styles.ring} ref={ringRef} role="img" aria-label="업로드 진행 원형 게이지">
<div className={styles.center}>
<p className={styles.pct}>{pct}%</p>
<p className={styles.label}>업로드 중</p>
</div>
</div>
<div className={styles.btns}>
<button className={styles.btn} data-minus type="button" aria-label="10% 줄이기">−</button>
<button className={styles.btn} data-plus type="button" aria-label="10% 늘리기">+</button>
</div>
</div>
</main>
);
}06 · 원형 게이지 바 — 값에 따라 각도로 채워지는 원형 진행률 표시 — 측정 누적면적 5.931% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07물리형 노브 다이얼 — 손잡이 자체가 눌리고 튀는 질감의 다이얼
매 프레임 목표까지의 거리에 SPRING_K(0.18) 를 곱해 힘을 만들고, 누적된 속도에 SPRING_DAMP(0.72) 를 곱해 흔들림을 잦아들게 합니다. 단순 보간과 달리 목표를 넘겼다 되돌아오는 튕김이 생겨, 누르면 0.86 으로 가라앉고 떼면 1.12 를 지나 1 로 돌아오는 무게감이 나옵니다.
// 계산 겹: 감쇠 스프링. 매 프레임 목표까지의 거리에 SPRING_K 를 곱해 가속하고,
// 누적 속도에 SPRING_DAMP 를 곱해 서서히 잦아들게 한다(단순 lerp 와 달리 목표를 넘겨 튕긴다).
function springStep() {
if (reduce) { scaleCur = scaleTarget; scaleVel = 0; return; }
var force = (scaleTarget - scaleCur) * SPRING_K;
scaleVel = (scaleVel + force) * SPRING_DAMP;
scaleCur += scaleVel;
}

<main class="stage">
<div class="kd is-demo" id="kd">
<button class="kd__knob" id="kdKnob" type="button" aria-label="눌러서 밝기 다이얼 튕기기">
<span class="kd__face"></span>
</button>
<p class="kd__value" id="kdValue">밝기 40%</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스프링 오버슛 다이얼 — 계산 겹은 index.html 의 감쇠 스프링(springStep)에 있다.
// 여기는 원형 손잡이 하나만 그리고 --scale 하나로 눌림·튕김을 전부 구동한다.
$duration: $dur-instant;
$easing: $ease-spring;
$color: $subject-orange-deep;
@include stage($stage-paper);
.kd { display: flex; flex-direction: column; align-items: center; gap: $sp-5; }
.kd__knob {
position: relative;
appearance: none;
border: none;
border-radius: 50%;
width: 148px;
height: 148px;
cursor: pointer;
background: radial-gradient(circle at 32% 28%, rgba($subject-cream, .95), $color 72%);
box-shadow: $shadow-raised;
transform: scale(var(--scale, 1));
transform-origin: center;
}
.kd__knob:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 4px; }
.kd__face {
position: absolute;
inset: 18px;
border-radius: 50%;
border: 2px solid rgba($subject-cream, .5);
}
.kd__face::after {
content: "";
position: absolute;
top: 10px;
left: calc(50% - 3px);
width: 6px;
height: 20px;
border-radius: $r-pill;
background: $subject-cream;
}
.kd__value {
margin: 0;
color: $subject-ink;
font-weight: 800;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.kd__knob { width: 108px; height: 108px; }
.kd__face { inset: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.kd__knob { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kd {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.kd__knob {
position: relative;
appearance: none;
border: none;
border-radius: 50%;
width: 148px;
height: 148px;
cursor: pointer;
background: radial-gradient(circle at 32% 28%, rgba(255, 247, 230, 0.95), #c93000 72%);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scale(var(--scale, 1));
transform-origin: center;
}
.kd__knob:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 4px;
}
.kd__face {
position: absolute;
inset: 18px;
border-radius: 50%;
border: 2px solid rgba(255, 247, 230, 0.5);
}
.kd__face::after {
content: "";
position: absolute;
top: 10px;
left: calc(50% - 3px);
width: 6px;
height: 20px;
border-radius: 999px;
background: #fff7e6;
}
.kd__value {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.kd__knob {
width: 108px;
height: 108px;
}
.kd__face {
inset: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.kd__knob {
transition: none;
}
}(function () {
var SPRING_K = 0.18; // 용수철 상수 — 클수록 목표로 더 빨리 당겨진다
var SPRING_DAMP = 0.72; // 감쇠 — 1에 가까울수록 오래 흔들린다
var LOOP_MS = 2000;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('kd');
var knob = document.getElementById('kdKnob');
var valueEl = document.getElementById('kdValue');
var isDemo = true;
var LEVELS = [10, 40, 70, 100];
var levelIdx = 1;
var scaleCur = 1;
var scaleVel = 0;
var scaleTarget = 1;
var pressed = false;
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 감쇠 스프링. 매 프레임 목표까지의 거리에 SPRING_K 를 곱해 가속하고,
// 누적 속도에 SPRING_DAMP 를 곱해 서서히 잦아들게 한다(단순 lerp 와 달리 목표를 넘겨 튕긴다).
function springStep() {
if (reduce) { scaleCur = scaleTarget; scaleVel = 0; return; }
var force = (scaleTarget - scaleCur) * SPRING_K;
scaleVel = (scaleVel + force) * SPRING_DAMP;
scaleCur += scaleVel;
}
function apply() {
knob.style.setProperty('--scale', scaleCur.toFixed(4));
valueEl.textContent = '밝기 ' + LEVELS[levelIdx] + '%';
}
function press() {
human();
pressed = true;
scaleTarget = 0.86;
}
function release() {
if (!pressed) return;
pressed = false;
levelIdx = (levelIdx + 1) % LEVELS.length;
scaleTarget = 1.12;
scaleVel += reduce ? 0 : 0.03;
setTimeout(function () { scaleTarget = 1; }, reduce ? 0 : 90);
}
knob.addEventListener('pointerdown', press);
knob.addEventListener('pointerup', release);
knob.addEventListener('pointerleave', function () { if (pressed) release(); });
knob.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); press(); release(); }
});
// 자동 루프: 누름 → 뗌 → 스프링 정착을 반복한다. 사람 조작과 같은 springStep()/apply() 를 쓴다.
function loop(ts) {
if (isDemo && !reduce) {
var phase = (ts % LOOP_MS) / LOOP_MS;
if (phase < 0.06) { scaleTarget = 0.86; }
else if (phase < 0.12 && scaleTarget !== 1.12) {
levelIdx = (levelIdx + 1) % LEVELS.length;
scaleTarget = 1.12;
} else if (phase >= 0.12 && phase < 0.2) { scaleTarget = 1; }
} else if (isDemo && reduce) {
scaleTarget = 1;
}
springStep();
apply();
requestAnimationFrame(loop);
}
apply();
requestAnimationFrame(loop);
})();// SpringKnobDial — vanilla 판(run/374/demo/07-arc-knob-dial/index.html) 의 React 포트.
// 계산 겹: 감쇠 스프링(springStep) — 매 프레임 목표까지의 거리에 SPRING_K 를 곱해 가속하고
// 누적 속도에 SPRING_DAMP 를 곱해 서서히 잦아들게 해 목표를 넘겨 튕기게 한다.
import { useEffect, useRef, useState } from "react";
import styles from "./SpringKnobDial.module.scss";
type Box<T> = { current: T };
const SPRING_K = 0.18;
const SPRING_DAMP = 0.72;
const LOOP_MS = 2000;
const LEVELS = [10, 40, 70, 100];
export default function SpringKnobDial() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const knobRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
const [level, setLevel] = useState(LEVELS[1]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const knob = knobRef.current;
if (!root || !knob) return;
let isDemo = true;
let levelIdx = 1;
let scaleCur = 1;
let scaleVel = 0;
let scaleTarget = 1;
let pressed = false;
let releaseTimer = 0;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function springStep() {
if (reduce) { scaleCur = scaleTarget; scaleVel = 0; return; }
const force = (scaleTarget - scaleCur) * SPRING_K;
scaleVel = (scaleVel + force) * SPRING_DAMP;
scaleCur += scaleVel;
}
function apply() {
knob!.style.setProperty("--scale", scaleCur.toFixed(4));
setLevel(LEVELS[levelIdx]);
}
function press() { human(); pressed = true; scaleTarget = 0.86; }
function release() {
if (!pressed) return;
pressed = false;
levelIdx = (levelIdx + 1) % LEVELS.length;
scaleTarget = 1.12;
scaleVel += reduce ? 0 : 0.03;
releaseTimer = window.setTimeout(function () { scaleTarget = 1; }, reduce ? 0 : 90);
}
function onDown() { press(); }
function onUp() { release(); }
function onLeave() { if (pressed) release(); }
function onKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); press(); release(); }
}
knob.addEventListener("pointerdown", onDown);
knob.addEventListener("pointerup", onUp);
knob.addEventListener("pointerleave", onLeave);
knob.addEventListener("keydown", onKeydown);
function loop(ts: number) {
if (isDemo && !reduce) {
const phase = (ts % LOOP_MS) / LOOP_MS;
if (phase < 0.06) { scaleTarget = 0.86; }
else if (phase < 0.12 && scaleTarget !== 1.12) {
levelIdx = (levelIdx + 1) % LEVELS.length;
scaleTarget = 1.12;
} else if (phase >= 0.12 && phase < 0.2) { scaleTarget = 1; }
} else if (isDemo && reduce) {
scaleTarget = 1;
}
springStep();
apply();
rafRef.current = requestAnimationFrame(loop);
}
apply();
rafRef.current = requestAnimationFrame(loop);
return () => {
knob.removeEventListener("pointerdown", onDown);
knob.removeEventListener("pointerup", onUp);
knob.removeEventListener("pointerleave", onLeave);
knob.removeEventListener("keydown", onKeydown);
window.clearTimeout(releaseTimer);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.kd + " " + styles.isDemo} ref={rootRef}>
<button className={styles.knob} ref={knobRef} type="button" aria-label="눌러서 밝기 다이얼 튕기기">
<span className={styles.face} />
</button>
<p className={styles.value}>밝기 {level}%</p>
</div>
</main>
);
}07 · 물리형 노브 다이얼 — 손잡이 자체가 눌리고 튀는 질감의 다이얼 — 측정 누적면적 18.514% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08원호 무드 슬라이더 — 반원을 따라 강도를 고르는 손잡이
포인터 좌표를 atan2 로 반원 각도(-180~0)로 바꾼 뒤 0~1 로 정규화한 t 하나가 좌표·색상·라벨을 동시에 구동합니다. t 로 다시 각도를 내 cos·sin 으로 손잡이 좌표를 계산하고, --hue 는 t * 130, 라벨 인덱스는 floor(t * N) — 값 하나가 세 출력을 함께 움직여 어긋나지 않습니다.
// 계산 겹: 포인터 좌표를 atan2 로 반원(180~360deg, 왼쪽 끝~오른쪽 끝) 각도로 바꾸고 0~1 로 정규화한다.
function angleToT(clientX, clientY) {
var r = arc.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.bottom;
var deg = Math.atan2(clientY - cy, clientX - cx) * 180 / Math.PI; // -180 ~ 0 (위쪽 반원)
var clamped = Math.min(0, Math.max(-180, deg));
return 1 - (clamped + 180) / 180;
}
function apply() {
var deg = -180 + t * 180;
var x = Math.cos(deg * Math.PI / 180);
var y = Math.sin(deg * Math.PI / 180);
handle.style.setProperty('--x', x.toFixed(4));
handle.style.setProperty('--y', y.toFixed(4));
handle.style.setProperty('--hue', Math.round(t * 130));
var idx = Math.min(N - 1, Math.floor(t * N));
labelEl.textContent = MOODS[idx];
}

<main class="stage">
<div class="ms is-demo" id="ms">
<div class="ms__arc" id="msArc">
<button class="ms__handle" id="msHandle" type="button" aria-label="반원을 따라 드래그해서 기분 고르기"></button>
</div>
<p class="ms__label" id="msLabel">보통</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 반원 무드 슬라이더 — 계산 겹은 index.html 의 atan2 각도→진행률(angleToT) 과 진행률→색상(hue) 매핑에 있다.
// 여기는 반원 트랙과 손잡이만 그리고 --x/--y/--hue 세 변수로 위치·색을 구동한다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-pink;
@include stage($stage-orange);
.ms { display: flex; flex-direction: column; align-items: center; gap: $sp-6; }
.ms__arc {
position: relative;
width: 220px;
height: 120px;
overflow: hidden;
touch-action: none;
}
.ms__arc::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 220px;
border-radius: 50%;
border: 10px solid rgba($subject-ink, .12);
clip-path: inset(0 0 50% 0);
}
.ms__handle {
position: absolute;
left: calc(50% + var(--x, 0) * 100px - 15px);
bottom: calc(var(--y, -1) * -100px - 15px);
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
cursor: grab;
background: hsl(calc(var(--hue, 60) * 1deg) 62% 68%);
box-shadow: $shadow-raised;
transition: background $duration $easing;
}
.ms__handle:active { cursor: grabbing; }
.ms__handle:focus-visible { outline: 2px solid $subject-ink; outline-offset: 3px; }
.ms__label {
margin: 0;
color: $subject-ink;
font-weight: 800;
font-size: 16px;
}
@media (max-width: 340px) {
.ms__arc { width: 168px; height: 92px; overflow: hidden; }
.ms__arc::before { height: 168px; }
.ms__handle {
left: calc(50% + var(--x, 0) * 76px - 12px);
bottom: calc(var(--y, -1) * -76px - 12px);
width: 24px;
height: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.ms__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ms {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.ms__arc {
position: relative;
width: 220px;
height: 120px;
overflow: hidden;
touch-action: none;
}
.ms__arc::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 220px;
border-radius: 50%;
border: 10px solid rgba(23, 20, 26, 0.12);
clip-path: inset(0 0 50% 0);
}
.ms__handle {
position: absolute;
left: calc(50% + var(--x, 0) * 100px - 15px);
bottom: calc(var(--y, -1) * -100px - 15px);
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
cursor: grab;
background: hsl(calc(var(--hue, 60) * 1deg), 62%, 68%);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: background 220ms ease-out;
}
.ms__handle:active {
cursor: grabbing;
}
.ms__handle:focus-visible {
outline: 2px solid #17141a;
outline-offset: 3px;
}
.ms__label {
margin: 0;
color: #17141a;
font-weight: 800;
font-size: 16px;
}
@media (max-width: 340px) {
.ms__arc {
width: 168px;
height: 92px;
overflow: hidden;
}
.ms__arc::before {
height: 168px;
}
.ms__handle {
left: calc(50% + var(--x, 0) * 76px - 12px);
bottom: calc(var(--y, -1) * -76px - 12px);
width: 24px;
height: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.ms__handle {
transition: none;
}
}(function () {
var MOODS = ['우울', '지침', '보통', '좋음', '최고'];
var N = MOODS.length;
var LOOP_MS = 2000;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('ms');
var arc = document.getElementById('msArc');
var handle = document.getElementById('msHandle');
var labelEl = document.getElementById('msLabel');
var isDemo = true;
var dragging = false;
var t = 0.5; // 0~1, 반원을 따라 진행한 위치
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 포인터 좌표를 atan2 로 반원(180~360deg, 왼쪽 끝~오른쪽 끝) 각도로 바꾸고 0~1 로 정규화한다.
function angleToT(clientX, clientY) {
var r = arc.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.bottom;
var deg = Math.atan2(clientY - cy, clientX - cx) * 180 / Math.PI; // -180 ~ 0 (위쪽 반원)
var clamped = Math.min(0, Math.max(-180, deg));
return 1 - (clamped + 180) / 180;
}
function apply() {
var deg = -180 + t * 180;
var x = Math.cos(deg * Math.PI / 180);
var y = Math.sin(deg * Math.PI / 180);
handle.style.setProperty('--x', x.toFixed(4));
handle.style.setProperty('--y', y.toFixed(4));
handle.style.setProperty('--hue', Math.round(t * 130));
var idx = Math.min(N - 1, Math.floor(t * N));
labelEl.textContent = MOODS[idx];
}
handle.addEventListener('pointerdown', function (e) {
human();
dragging = true;
handle.setPointerCapture(e.pointerId);
t = angleToT(e.clientX, e.clientY);
apply();
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
t = angleToT(e.clientX, e.clientY);
apply();
});
handle.addEventListener('pointerup', function () { dragging = false; });
handle.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { human(); t = Math.min(1, t + 1 / (N - 1)); apply(); }
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { human(); t = Math.max(0, t - 1 / (N - 1)); apply(); }
});
// 자동 루프: 반원 끝에서 끝까지 왕복하며 같은 angleToT 대응 함수(t) 로 apply() 를 구동한다.
function loop(ts) {
if (isDemo) {
if (!reduce) {
var phase = (ts % LOOP_MS) / LOOP_MS;
t = phase < 0.5 ? phase * 2 : 2 - phase * 2;
} else {
t = 0.5;
}
apply();
}
requestAnimationFrame(loop);
}
apply();
requestAnimationFrame(loop);
})();// MoodArcSlider — vanilla 판(run/374/demo/08-arc-mood-slider/index.html) 의 React 포트.
// 계산 겹: 포인터 좌표를 atan2 로 반원 각도로 바꾼 뒤(angleToT) 0~1 진행률로 정규화하고,
// 그 진행률을 hue 로 그대로 매핑해 색이 기분과 함께 바뀐다.
import { useEffect, useRef, useState } from "react";
import styles from "./MoodArcSlider.module.scss";
type Box<T> = { current: T };
const MOODS = ["우울", "지침", "보통", "좋음", "최고"];
const N = MOODS.length;
const LOOP_MS = 2000;
export default function MoodArcSlider() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const arcRef = 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 [label, setLabel] = useState(MOODS[2]);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const arc = arcRef.current;
const handle = handleRef.current;
if (!root || !arc || !handle) return;
let isDemo = true;
let dragging = false;
let t = 0.5;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function angleToT(clientX: number, clientY: number) {
const r = arc!.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.bottom;
const deg = (Math.atan2(clientY - cy, clientX - cx) * 180) / Math.PI;
const clamped = Math.min(0, Math.max(-180, deg));
return 1 - (clamped + 180) / 180;
}
function apply() {
const deg = -180 + t * 180;
const x = Math.cos((deg * Math.PI) / 180);
const y = Math.sin((deg * Math.PI) / 180);
handle!.style.setProperty("--x", x.toFixed(4));
handle!.style.setProperty("--y", y.toFixed(4));
handle!.style.setProperty("--hue", String(Math.round(t * 130)));
const idx = Math.min(N - 1, Math.floor(t * N));
setLabel(MOODS[idx]);
}
function onDown(e: PointerEvent) {
human();
dragging = true;
handle!.setPointerCapture(e.pointerId);
t = angleToT(e.clientX, e.clientY);
apply();
}
function onMove(e: PointerEvent) { if (!dragging) return; t = angleToT(e.clientX, e.clientY); apply(); }
function onUp() { dragging = false; }
function onKeydown(e: KeyboardEvent) {
if (e.key === "ArrowRight" || e.key === "ArrowUp") { human(); t = Math.min(1, t + 1 / (N - 1)); apply(); }
if (e.key === "ArrowLeft" || e.key === "ArrowDown") { human(); t = Math.max(0, t - 1 / (N - 1)); apply(); }
}
handle.addEventListener("pointerdown", onDown);
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
handle.addEventListener("keydown", onKeydown);
function loop(ts: number) {
if (isDemo) {
if (!reduce) {
const phase = (ts % LOOP_MS) / LOOP_MS;
t = phase < 0.5 ? phase * 2 : 2 - phase * 2;
} else {
t = 0.5;
}
apply();
}
rafRef.current = requestAnimationFrame(loop);
}
apply();
rafRef.current = requestAnimationFrame(loop);
return () => {
handle.removeEventListener("pointerdown", onDown);
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", onUp);
handle.removeEventListener("keydown", onKeydown);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.ms + " " + styles.isDemo} ref={rootRef}>
<div className={styles.arc} ref={arcRef}>
<button className={styles.handle} ref={handleRef} type="button" aria-label="반원을 따라 드래그해서 기분 고르기" />
</div>
<p className={styles.label}>{label}</p>
</div>
</main>
);
}08 · 원호 무드 슬라이더 — 반원을 따라 강도를 고르는 손잡이 — 측정 누적면적 8.369% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09키보드 조작 원형 컨트롤 — 화살표 키로 눈금을 옮기는 접근성 다이얼
valueToDeg() 가 0~100 값을 RANGE_DEG(300) 범위의 각도로 바꾸고, 화살표 키 한 번은 STEP_DEG(12)/RANGE_DEG 비율만큼 값을 옮깁니다. 요소는 role="slider" 에 aria-valuemin·aria-valuemax·aria-valuenow·aria-valuetext 를 갖추고 있어 값이 바뀔 때마다 스크린리더가 현재 값을 읽습니다.
// 계산 겹: 값(%) 을 각도로 바꾼다. RANGE_DEG 범위 안에서 0%→시작각, 100%→끝각.
function valueToDeg(v) {
return -RANGE_DEG / 2 + (Math.min(100, Math.max(0, v)) / 100) * RANGE_DEG;
}
function apply() {
handle.style.setProperty('--deg', valueToDeg(value));
handle.setAttribute('aria-valuenow', String(Math.round(value)));
handle.setAttribute('aria-valuetext', Math.round(value) + '%');
}
function setValue(v) { value = Math.min(100, Math.max(0, v)); apply(); }
// 계산 겹: 키 입력 한 번 = STEP_DEG 만큼의 각도 = RANGE_DEG 대비 그 비율만큼의 퍼센트.
var stepPct = (STEP_DEG / RANGE_DEG) * 100;
handle.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); setValue(value + stepPct); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); setValue(value - stepPct); }
else if (e.key === 'Home') { e.preventDefault(); human(); setValue(0); }
else if (e.key === 'End') { e.preventDefault(); human(); setValue(100); }
});

<main class="stage">
<div class="ka is-demo" id="ka">
<div class="ka__ring" id="kaRing">
<div class="ka__handle"
id="kaHandle"
role="slider"
tabindex="0"
aria-label="화살표 키로 조절하는 재생 볼륨"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="40"
aria-valuetext="40%"></div>
</div>
<p class="ka__hint">← → 로 볼륨 조절</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 키보드 원형 슬라이더 — 계산 겹은 index.html 의 valueToDeg(값→각도)·stepPct(각도 한 걸음→퍼센트)에 있다.
// 여기는 트랙 호와 손잡이만 그리고 --deg 하나로 위치를 구동한다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-ink);
.ka { display: flex; flex-direction: column; align-items: center; gap: $sp-4; }
.ka__ring {
position: relative;
width: 180px;
height: 180px;
border-radius: 50%;
background: conic-gradient(from -150deg, rgba($subject-cream, .16) 0deg 300deg, transparent 300deg 360deg);
}
.ka__handle {
position: absolute;
top: 50%;
left: 50%;
width: 26px;
height: 26px;
border-radius: 50%;
background: $color;
box-shadow: $shadow-raised;
cursor: pointer;
transform: rotate(calc(var(--deg, -150) * 1deg)) translate(76px) rotate(calc(var(--deg, -150) * -1deg)) translate(-50%, -50%);
transition: transform $duration $easing;
}
.ka__handle:focus-visible { outline: 3px solid $subject-cream; outline-offset: 2px; }
.ka__hint {
margin: 0;
color: $subject-cream;
font-size: 11px;
font-weight: 600;
opacity: .78;
}
@media (max-width: 340px) {
.ka__ring { width: 132px; height: 132px; }
.ka__handle { transform: rotate(calc(var(--deg, -150) * 1deg)) translate(54px) rotate(calc(var(--deg, -150) * -1deg)) translate(-50%, -50%); }
}
@media (prefers-reduced-motion: reduce) {
.ka__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);
}
.ka {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.ka__ring {
position: relative;
width: 180px;
height: 180px;
border-radius: 50%;
background: conic-gradient(from -150deg, rgba(255, 247, 230, 0.16) 0deg 300deg, transparent 300deg 360deg);
}
.ka__handle {
position: absolute;
top: 50%;
left: 50%;
width: 26px;
height: 26px;
border-radius: 50%;
background: #6ec8ff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: pointer;
transform: rotate(calc(var(--deg, -150) * 1deg)) translate(76px) rotate(calc(var(--deg, -150) * -1deg)) translate(-50%, -50%);
transition: transform 220ms ease-out;
}
.ka__handle:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 2px;
}
.ka__hint {
margin: 0;
color: #fff7e6;
font-size: 11px;
font-weight: 600;
opacity: 0.78;
}
@media (max-width: 340px) {
.ka__ring {
width: 132px;
height: 132px;
}
.ka__handle {
transform: rotate(calc(var(--deg, -150) * 1deg)) translate(54px) rotate(calc(var(--deg, -150) * -1deg)) translate(-50%, -50%);
}
}
@media (prefers-reduced-motion: reduce) {
.ka__handle {
transition: none;
}
}(function () {
var STEP_DEG = 12; // 화살표 키 한 번에 움직이는 각도 — 100% = 300deg 범위이므로 STEP_DEG 만큼이 값 한 걸음
var RANGE_DEG = 300; // 슬라이더가 쓰는 전체 각도(위쪽 60deg 는 비움)
var LOOP_MS = 2000;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('ka');
var handle = document.getElementById('kaHandle');
var isDemo = true;
var value = 40; // 0~100
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
// 계산 겹: 값(%) 을 각도로 바꾼다. RANGE_DEG 범위 안에서 0%→시작각, 100%→끝각.
function valueToDeg(v) {
return -RANGE_DEG / 2 + (Math.min(100, Math.max(0, v)) / 100) * RANGE_DEG;
}
function apply() {
handle.style.setProperty('--deg', valueToDeg(value));
handle.setAttribute('aria-valuenow', String(Math.round(value)));
handle.setAttribute('aria-valuetext', Math.round(value) + '%');
}
function setValue(v) { value = Math.min(100, Math.max(0, v)); apply(); }
// 계산 겹: 키 입력 한 번 = STEP_DEG 만큼의 각도 = RANGE_DEG 대비 그 비율만큼의 퍼센트.
var stepPct = (STEP_DEG / RANGE_DEG) * 100;
handle.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); setValue(value + stepPct); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); setValue(value - stepPct); }
else if (e.key === 'Home') { e.preventDefault(); human(); setValue(0); }
else if (e.key === 'End') { e.preventDefault(); human(); setValue(100); }
});
handle.addEventListener('pointerdown', function () { human(); handle.focus({ preventScroll: true }); });
// 자동 루프: 화살표 키를 누른 것과 똑같이 stepPct 만큼씩 값을 올렸다 끝에서 되돌린다.
var dir = 1;
var acc = 0;
function loop(ts) {
if (isDemo && !reduce) {
acc += 16.7;
if (acc >= LOOP_MS / 12) {
acc = 0;
value += dir * stepPct;
if (value >= 100) { value = 100; dir = -1; }
if (value <= 0) { value = 0; dir = 1; }
apply();
}
} else if (isDemo && reduce) {
value = 40;
apply();
}
requestAnimationFrame(loop);
}
apply();
requestAnimationFrame(loop);
})();// KeyboardArcSlider — vanilla 판(run/374/demo/09-arc-keyboard-a11y/index.html) 의 React 포트.
// 계산 겹: valueToDeg(값→각도)와 stepPct(화살표 키 한 걸음의 각도를 퍼센트로 환산). 실제 role="slider" +
// aria-valuenow 를 쓰고 화살표 키가 진짜로 값을 움직인다.
import { useEffect, useRef, useState } from "react";
import styles from "./KeyboardArcSlider.module.scss";
type Box<T> = { current: T };
const STEP_DEG = 12;
const RANGE_DEG = 300;
const LOOP_MS = 2000;
export default function KeyboardArcSlider() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const handleRef = useRef(null) as unknown as Box<HTMLDivElement>;
const rafRef = useRef(0) as unknown as Box<number>;
const [value, setValueState] = useState(40);
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const handle = handleRef.current;
if (!root || !handle) return;
let isDemo = true;
let value = 40;
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function valueToDeg(v: number) {
return -RANGE_DEG / 2 + (Math.min(100, Math.max(0, v)) / 100) * RANGE_DEG;
}
function apply() {
handle!.style.setProperty("--deg", String(valueToDeg(value)));
handle!.setAttribute("aria-valuenow", String(Math.round(value)));
handle!.setAttribute("aria-valuetext", Math.round(value) + "%");
setValueState(Math.round(value));
}
function setValue(v: number) { value = Math.min(100, Math.max(0, v)); apply(); }
const stepPct = (STEP_DEG / RANGE_DEG) * 100;
function onKeydown(e: KeyboardEvent) {
if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); human(); setValue(value + stepPct); }
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); human(); setValue(value - stepPct); }
else if (e.key === "Home") { e.preventDefault(); human(); setValue(0); }
else if (e.key === "End") { e.preventDefault(); human(); setValue(100); }
}
function onDown() { human(); handle!.focus({ preventScroll: true }); }
handle.addEventListener("keydown", onKeydown);
handle.addEventListener("pointerdown", onDown);
let dir = 1;
let acc = 0;
function loop(ts: number) {
if (isDemo && !reduce) {
acc += 16.7;
if (acc >= LOOP_MS / 12) {
acc = 0;
value += dir * stepPct;
if (value >= 100) { value = 100; dir = -1; }
if (value <= 0) { value = 0; dir = 1; }
apply();
}
} else if (isDemo && reduce) {
value = 40;
apply();
}
rafRef.current = requestAnimationFrame(loop);
}
apply();
rafRef.current = requestAnimationFrame(loop);
return () => {
handle.removeEventListener("keydown", onKeydown);
handle.removeEventListener("pointerdown", onDown);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.ka + " " + styles.isDemo} ref={rootRef}>
<div className={styles.ring}>
<div
className={styles.handle}
ref={handleRef}
role="slider"
tabIndex={0}
aria-label="화살표 키로 조절하는 재생 볼륨"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
aria-valuetext={value + "%"}
/>
</div>
<p className={styles.hint}>← → 로 볼륨 조절</p>
</div>
</main>
);
}09 · 키보드 조작 원형 컨트롤 — 화살표 키로 눈금을 옮기는 접근성 다이얼 — 측정 누적면적 2.96% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 손잡이를 바꾸는 곳 — 각도·관성·스냅·초점 기준
한 걸음을 정하는 상수들
아홉 개의 성질은 이름 붙은 상수값 하나하나가 정합니다 — 01 의 ARC_SPAN_DEG(200)·RADIUS(96), 02 의 STEP_DEG(=360/7)·HUE_STEP_DEG(=360/7), 04 의 DRAG_DECAY(0.92)·MIN_VELOCITY(0.15), 05 의 NOTCH_DEG·SNAP_THRESHOLD_DEG(20)·SETTLE_MS(260), 06 의 GAUGE_STEP_DEG(6), 07 의 SPRING_K(0.18)·SPRING_DAMP(0.72), 09 의 STEP_DEG(12)·RANGE_DEG(300) 입니다. css 도넛 차트처럼 눈으로 보이는 결과가 비슷해도 값을 손대는 자리는 이 상수들이고, 채움 속도처럼 상수가 아닌 값(06 의 0.16 보간 계수)은 소스 안에 자리를 따로 두고 있습니다.
이웃 가지 — 원형 컨트롤 축의 다른 편
값이 고리를 채우는 모양만 필요하면 원형 프로그레스바 CSS 9종 — 값만 바꾸면 되는가 값 표시에 더 가깝고, 이 편은 사람이 손으로 조작하는 컨트롤을 다룹니다. 손잡이를 밀어 멈추는 물리는 룰렛 돌리기 — 튕겨 돌리고 마찰로 멈추는 돌림판 캔버스 9종이 마찰 감쇠 축에서 다루는데, 이 편의 04 관성은 사용자가 돌린 방향과 속도를 그대로 잇는 점에서 갈립니다. 직선 축의 대응물은 css input range 슬라이더 — 관성·스냅 계산을 얹은 커스텀 9종입니다. 각도 계산의 근거는 conic-gradient 문서와 Pointer events 문서를 따릅니다.
어디서 깨지는가 — 터치·키보드 대응, 폴백, reduced-motion
원형 컨트롤이 실제로 깨지는 지점은 화면이 아니라 입력에서 먼저 옵니다. 드래그를 쓰는 다섯 항목 중 04 만 pointercancel 을 받는데, 터치 기기에서 브라우저가 제스처를 가로채면 pointerup 없이 pointercancel 이 오므로 이 이벤트를 놓치면 손잡이가 드래그 상태에 걸린 채 남습니다. 03 은 pointermove 호버만 쓰므로 터치 기기에서는 탭이 pointermove 를 한 번 내는 정도로만 반응합니다 — 데스크톱 마우스용 패턴으로 보는 것이 맞습니다. 02·08 은 setPointerCapture 로 포인터를 붙잡아 요소 밖으로 나가도 드래그가 이어지고, 09 는 pointerdown 이 값을 바꾸는 게 아니라 초점을 옮기는 데만 써서 조작은 전부 키보드가 담당합니다. 각도를 그대로 클래스 토글로만 쓰면 여러 바퀴를 돌았을 때 02 의 활성 인덱스가 어긋나는데, ((-rotation % 360) + 360) % 360 정규화가 그 어긋남을 막는 겹입니다.
아홉 컨트롤의 html·scss·js 소스 전체는 ydmj5nha 압축 비밀번호로 받아 상수를 직접 바꿔가며 열어볼 수 있습니다.
reduced-motion 이 남기는 것
reduce 설정이 켜지면 자동 시연 대신 정지 상태가 남는데, 04 는 decayStep() 안에서, 05 는 SETTLE_MS 계산에서, 06 은 보간 계수에서, 07 은 springStep() 안에서 reduce 를 자동 시연 조건(isDemo) 과 무관하게 검사해 사람이 조작하기 시작한 뒤에도 계속 지켜집니다 — 04 는 velocity 를 0 으로 놓아 관성 없이 즉시 멈추고, 05 는 SETTLE_MS 가 0 이 되어 튕김 없이 눈금에 바로 앉으며, 06 은 보간 계수가 1 이 되어 클릭 즉시 목표 각도를 보여 주고, 07 은 springStep() 이 현재값을 목표에 바로 붙입니다. 01·02·03·08·09 는 reduce 검사가 자동 시연 루프 안에만 있어 실사용 전환은 css prefers-reduced-motion 블록이 대신 끊는데, 01 은 펼쳐진 메뉴를, 09 는 40% 상태를 시연 화면에서 고정해 보여 줍니다.
한 화면에 원형 다이얼 아홉을 몰아 넣으면 requestAnimationFrame 루프가 동시에 아홉 개 도는 셈이라 저사양 기기에서는 프레임이 밀릴 수 있습니다 — 실서비스에 붙일 때는 필요한 컨트롤 하나만 남기는 편이 안전합니다.
변형 세 가지와 접근성
| 변형 | 값 | 느낌 |
|---|---|---|
| 드래그 관성 길게 남기기 | 04 DRAG_DECAY 0.92 → 0.98 | 감쇠율을 1에 더 가깝게 올리면 decayStep() 에서 손을 뗀 뒤 속도에 매 프레임 곱해지는 값이 커져(velocity *= DRAG_DECAY) 회전 속도가 더 천천히 줄어들고 관성이 더 오래 남는다 — 손잡이가 미끄러지듯 멀리 간다 |
| 스냅 판정 범위 좁히기 | 05 SNAP_THRESHOLD_DEG 20 → 10 | 판정 범위를 좁히면 nearestNotch() 가 재는 거리(n.dist)가 이 값 이하일 때만 스냅하므로 눈금에 더 가까이 다가가야만 스냅이 걸려 놓는 위치에 덜 관대해진다 — 정밀 조작 느낌이 강해진다 |
| 게이지 채움을 더 매끈하게 | 06 GAUGE_STEP_DEG 6 → 3 | GAUGE_STEP_DEG 는 quantizedDeg() 가 표시 각도를 끊어 내리는 단위일 뿐 채워지는 속도(0.16 보간 계수·LOOP_MS)와는 무관하다 — 낮추면 계단처럼 보이던 채움 단위가 더 촘촘해져 같은 속도로도 더 매끈하게 보인다 |
접근성 면에서 09 는 role="slider" 와 aria-valuenow·aria-valuetext 로 값이 스크린리더에 읽히고 Home·End 키로 양 끝에 바로 갑니다. 01 은 role="menu"·menuitem 에 aria-expanded 로 열림을 알리고 닫힌 동안 항목의 tabIndex 를 -1 로 둬 탭 순서를 침범하지 않으며 Escape 로 닫힙니다. 02 는 role="listbox" 로 방향키를 받고, 04·05·07·08 은 aria-label 이 붙은 실제 button 이라 Enter·Space 로 그대로 작동합니다. 03 의 구간 조각과 05 의 눈금은 장식이라 aria-hidden 으로 숨겼고, 06 은 고리를 role="img" 로 라벨과 함께 읽히게 합니다. reduce 를 켠 환경에서는 앞 절의 정지 상태가 애니메이션 없이 그대로 남습니다.
FAQ
css 프로그레스바 애니메이션 효과를 이 게이지에 그대로 얹을 수 있나요?
06 이 바로 그 조합입니다 — 채움은 conic-gradient 로 그리고 값이 다가가는 속도는 0.16 보간 계수가 정하며, 표시 각도는 GAUGE_STEP_DEG 배수로 끊어 계단형으로 보입니다. 나머지 여덟은 값을 사람 조작으로 바꾸는 컨트롤이라, 진행률이 저절로 오르는 화면이 필요하면 06 만 떼어 쓰는 편이 맞습니다.
원형 다이얼을 키보드만으로 조작할 수 있나요?
09 가 그 역할 전용입니다 — 화살표 키 한 번이 STEP_DEG(12)/RANGE_DEG(300) 비율만큼 값을 옮기고 Home·End 로 양 끝에 갑니다. 그 외에 01·02·07·08 도 keydown 을 받아 Escape·방향키·Enter·Space 로 조작되고, 09 가 아니면 초점과 값 읽기는 각자의 role 과 aria 속성 수준에서 제공됩니다.
아홉 항목 중 화면을 가장 크게 바꾸는 건 어느 쪽인가요?
번지는 폭으로는 누적면적 23.78% 의 02 가, 픽셀 차의 셈으로는 강도 181.2 의 06 이 가장 큽니다 — 02 는 원판 전체가 돌고 06 은 고리 채움의 대비가 좁은 영역에 집중되기 때문입니다. 계산량 자체는 모두 가볍지만, 한 페이지에 아홉을 다 넣으면 루프가 겹쳐 저사양 기기에서 프레임이 밀릴 수 있으니 필요한 것만 남기는 것이 실서비스의 정답입니다.









