03단계에서 튕기며 멈추는 난이도 슬라이더
손을 떼면 손잡이가 가장 가까운 단계를 살짝 지나쳤다가 스프링 계산으로 되튕겨 그 단계에 멈춘다.
쓰임설정 화면에서 난이도를 손가락으로 끌어 정하는 자리
springTotierPctpaintAt

<main class="stage">
<div class="sst is-demo" id="sst">
<div class="sst__track" id="sstTrack">
<div class="sst__tick" style="left:0%"></div>
<div class="sst__tick" style="left:25%"></div>
<div class="sst__tick" style="left:50%"></div>
<div class="sst__tick" style="left:75%"></div>
<div class="sst__tick" style="left:100%"></div>
<div class="sst__fill" id="sstFill"></div>
<button type="button" class="sst__thumb" id="sstThumb" role="slider"
aria-label="난이도" aria-valuemin="0" aria-valuemax="4" aria-valuenow="1" aria-valuetext="보통" tabindex="0"></button>
</div>
<p class="sst__label" id="sstLabel">보통</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-orange-deep;
// 계산 겹: springTo() 가 스프링-감쇠 방정식을 매 프레임 적분한다(JS) — scss 는 위치만 반영, 트랜지션 없음(연속 프레임이 이미 물리 곡선)
@include stage($stage-orange);
.sst { width: min(280px, 84vw); color: $subject-ink; text-align: center; }
.sst__track { position: relative; height: 6px; border-radius: $r-pill; background: rgba($subject-ink, .18); margin: $sp-10 0 $sp-4; }
.sst__fill { position: absolute; top: 0; bottom: 0; left: 0; width: 25%; background: $color; border-radius: $r-pill; }
.sst__tick {
position: absolute; top: 50%; width: 4px; height: 4px; border-radius: 50%;
background: rgba($subject-ink, .35); transform: translate(-50%, -50%);
}
.sst__thumb {
position: absolute; top: 50%; left: 25%; width: 18px; height: 18px;
appearance: none; border: 0; border-radius: 50%; background: $subject-ink;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%);
}
.sst__thumb:active { cursor: grabbing; }
.sst__thumb:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.sst__label { margin: $sp-2 0 0; font-weight: 700; font-size: 16px; }
@media (max-width: 340px) {
.sst { width: min(240px, 80vw); }
.sst__label { font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 적용: springTo() 안에서 reduce 를 직접 확인해 목표 위치로 즉시 이동한다(JS 쪽 처리, 여긴 대체 표시 없음)
.sst__thumb { 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);
}
.sst {
width: min(280px, 84vw);
color: #17141a;
text-align: center;
}
.sst__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
margin: 40px 0 16px;
}
.sst__fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 25%;
background: #c93000;
border-radius: 999px;
}
.sst__tick {
position: absolute;
top: 50%;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.35);
transform: translate(-50%, -50%);
}
.sst__thumb {
position: absolute;
top: 50%;
left: 25%;
width: 18px;
height: 18px;
appearance: none;
border: 0;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
}
.sst__thumb:active {
cursor: grabbing;
}
.sst__thumb:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.sst__label {
margin: 8px 0 0;
font-weight: 700;
font-size: 16px;
}
@media (max-width: 340px) {
.sst {
width: min(240px, 80vw);
}
.sst__label {
font-size: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.sst__thumb {
transition: none;
}
}(function () {
var LEVELS = ['쉬움', '보통', '어려움', '매우 어려움', '최고'];
var N = LEVELS.length;
var SPRING_K = 210, SPRING_D = 18, DT = 1 / 60;
var root = document.getElementById('sst');
var track = document.getElementById('sstTrack');
var fill = document.getElementById('sstFill');
var thumb = document.getElementById('sstThumb');
var label = document.getElementById('sstLabel');
var tier = 1;
var pos = tierPct(1);
var raf = 0;
function tierPct(t) { return (t / (N - 1)) * 100; }
function paintAt(pct) {
thumb.style.left = pct + '%';
fill.style.width = pct + '%';
}
function setReadout(t) {
thumb.setAttribute('aria-valuenow', String(t));
thumb.setAttribute('aria-valuetext', LEVELS[t]);
label.textContent = LEVELS[t];
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 목표 단계까지 스프링-감쇠 방정식(가속도 = -k*(위치-목표) - d*속도)을 매 프레임 적분해
// 단계를 살짝 지나쳤다가(오버슛) 되튕겨 정확히 그 단계에서 멈춘다
function springTo(target) {
cancelAnimationFrame(raf);
if (reduce) { pos = target; paintAt(pos); return; }
var vel = 0;
function step() {
var force = -SPRING_K * (pos - target) - SPRING_D * vel;
vel += force * DT;
pos += vel * DT;
paintAt(pos);
if (Math.abs(pos - target) > 0.15 || Math.abs(vel) > 0.15) raf = requestAnimationFrame(step);
else { pos = target; paintAt(pos); }
}
raf = requestAnimationFrame(step);
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function settle(t) { tier = Math.max(0, Math.min(N - 1, t)); setReadout(tier); springTo(tierPct(tier)); }
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100));
}
thumb.addEventListener('pointerdown', function (e) {
human(); thumb.setPointerCapture(e.pointerId); cancelAnimationFrame(raf);
function move(ev) { pos = pctFromX(ev.clientX); paintAt(pos); }
function up() {
thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up);
settle(Math.round(pos / 100 * (N - 1)));
}
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); settle(tier + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); settle(tier - 1); }
else if (e.key === 'Home') { e.preventDefault(); human(); settle(0); }
else if (e.key === 'End') { e.preventDefault(); human(); settle(N - 1); }
});
var sequence = [3, 0, 4, 1, 2];
var si = 0;
function tick() { settle(sequence[si % sequence.length]); si++; }
var timer = reduce ? null : setInterval(tick, 900);
setReadout(tier); paintAt(pos);
}());import { useEffect, useRef } from 'react';
import styles from './DifficultySelectorSliderSnapTier.module.scss';
const LEVELS = ['쉬움', '보통', '어려움', '매우 어려움', '최고'];
const N = LEVELS.length;
const SPRING_K = 210, SPRING_D = 18, DT = 1 / 60;
const SEQUENCE = [3, 0, 4, 1, 2];
const TICK_MS = 900;
export default function DifficultySelectorSliderSnapTier() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLButtonElement>(null);
const labelRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const thumb = thumbRef.current;
const label = labelRef.current;
if (!root || !track || !fill || !thumb || !label) return undefined;
function tierPct(t: number) { return (t / (N - 1)) * 100; }
let tier = 1;
let pos = tierPct(1);
let raf = 0;
function paintAt(pct: number) {
thumb!.style.left = pct + '%';
fill!.style.width = pct + '%';
}
function setReadout(t: number) {
thumb!.setAttribute('aria-valuenow', String(t));
thumb!.setAttribute('aria-valuetext', LEVELS[t]);
label!.textContent = LEVELS[t];
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹: 목표 단계까지 스프링-감쇠 방정식(가속도 = -k*(위치-목표) - d*속도)을 매 프레임 적분해
// 단계를 살짝 지나쳤다가(오버슛) 되튕겨 정확히 그 단계에서 멈춘다
function springTo(target: number) {
cancelAnimationFrame(raf);
if (reduce) { pos = target; paintAt(pos); return; }
let vel = 0;
function step() {
const force = -SPRING_K * (pos - target) - SPRING_D * vel;
vel += force * DT;
pos += vel * DT;
paintAt(pos);
if (Math.abs(pos - target) > 0.15 || Math.abs(vel) > 0.15) raf = requestAnimationFrame(step);
else { pos = target; paintAt(pos); }
}
raf = requestAnimationFrame(step);
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function settle(t: number) { tier = Math.max(0, Math.min(N - 1, t)); setReadout(tier); springTo(tierPct(tier)); }
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100));
}
function onDown(e: PointerEvent) {
human(); thumb!.setPointerCapture(e.pointerId); cancelAnimationFrame(raf);
function move(ev: PointerEvent) { pos = pctFromX(ev.clientX); paintAt(pos); }
function up() {
thumb!.removeEventListener('pointermove', move); thumb!.removeEventListener('pointerup', up);
settle(Math.round(pos / 100 * (N - 1)));
}
thumb!.addEventListener('pointermove', move);
thumb!.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); human(); settle(tier + 1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); human(); settle(tier - 1); }
else if (e.key === 'Home') { e.preventDefault(); human(); settle(0); }
else if (e.key === 'End') { e.preventDefault(); human(); settle(N - 1); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
let si = 0;
function tick() { settle(SEQUENCE[si % SEQUENCE.length]); si++; }
if (!reduce) timer = window.setInterval(tick, TICK_MS);
setReadout(tier); paintAt(pos);
return () => {
thumb!.removeEventListener('pointerdown', onDown);
thumb!.removeEventListener('keydown', onKey);
cancelAnimationFrame(raf);
window.clearInterval(timer);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.sst + ' ' + styles['is-demo']} ref={rootRef}>
<div className={styles.sstTrack} ref={trackRef}>
<div className={styles.sstTick} style={{ left: '0%' }} />
<div className={styles.sstTick} style={{ left: '25%' }} />
<div className={styles.sstTick} style={{ left: '50%' }} />
<div className={styles.sstTick} style={{ left: '75%' }} />
<div className={styles.sstTick} style={{ left: '100%' }} />
<div className={styles.sstFill} ref={fillRef} />
<button
type="button"
className={styles.sstThumb}
ref={thumbRef}
role="slider"
aria-label="난이도"
aria-valuemin={0}
aria-valuemax={N - 1}
aria-valuenow={1}
aria-valuetext="보통"
tabIndex={0}
/>
</div>
<p className={styles.sstLabel} ref={labelRef}>보통</p>
</div>
</main>
);
}03 단계에서 튕기며 멈추는 난이도 슬라이더 — 측정 누적면적 4.291% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 난이도 선택 ui 9종 — 드래그 각도로 값이 바뀌는 계산까지
이 데모가 속한 글
난이도 선택 ui 9종 — 드래그 각도로 값이 바뀌는 계산까지난이도 선택 ui 아홉 가지 가운데 하나는 손을 떼면 슬라이더 손잡이가 단계를 살짝 지나쳤다가 스프링 계산으로 되튕겨 멈추고, 하나는 배지 다섯 개의 색을 고정값이 아니라 단계 번호를 넣은 색상각 수식으로 계산하며, 이 글의 데모 전부 보기


