08원호 무드 슬라이더 — 반원을 따라 강도를 고르는 손잡이
손잡이가 반원 경로를 따라 움직이며 위치에 따라 색과 값이 함께 바뀌어 기분이나 강도를 표현합니다.
쓰임기분 기록 체크인 카드, 알림 강도 설정
angleToTMath.atan2hsl(

<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 포함 · css 게이지 바 9종 — 손잡이가 계산으로 도는 원형 컨트롤
이 데모가 속한 글
css 게이지 바 9종 — 손잡이가 계산으로 도는 원형 컨트롤아홉 개의 원형 컨트롤 중 하나는 드래그 각도 값이 관성으로 감쇠하며 손잡이가 서서히 멈추고, 하나는 각 구간 값이 각도로 변환되어 css 게이지 바가 원을 그리며 채워지고, 하나는 스냅 판정 값에 따라 가장 가까운 이 글의 데모 전부 보기


