08Arc mood slider — a handle traveling a half-circle to set intensity
The handle travels a half-circle path, and its position shifts both color and value together to express intensity.
Best forA mood check-in card, a notification-intensity setting
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="Drag along the arc to pick a mood"></button>
</div>
<p class="ms__label" id="msLabel">Okay</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 = ['Down', 'Tired', 'Okay', 'Good', 'Great'];
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 Arc mood slider — a handle traveling a half-circle to set intensity — Measured area 8.369% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Radial Menu: 9 Circular Control Patterns
The post this demo lives in
CSS Radial Menu: 9 Circular Control PatternsNine css radial menu style controls: one decays a dragged angle with inertia, one turns a value into a conic-gradient ri See all demos in this post


