02장르 선택 휠 — 카테고리를 원판처럼 돌려 고르는 방식
카테고리 원판을 돌리면 중앙을 지나는 항목이 커지고 선명해지며 선택 후보로 바뀝니다.
쓰임음악 앱 장르 고르기, 취향 온보딩 화면
activeIndexSTEP_DEGhsl(

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

