03A Difficulty Slider That Overshoots and Springs Back Onto Its Tier
Releasing the handle lets it slide slightly past the nearest tier, then a spring calculation pulls it back to settle exactly on that tier.
Best forA place to drag a finger to set difficulty on a settings screen
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="Difficulty" aria-valuemin="0" aria-valuemax="4" aria-valuenow="1" aria-valuetext="Normal" tabindex="0"></button>
</div>
<p class="sst__label" id="sstLabel">Normal</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 = ['Easy', 'Normal', 'Hard', 'Very Hard', 'Extreme'];
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 A Difficulty Slider That Overshoots and Springs Back Onto Its Tier — Measured area 4.291% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · Difficulty Selector UI — 9 Computed Patterns
The post this demo lives in
Difficulty Selector UI — 9 Computed PatternsAmong nine difficulty selector ui patterns, a slider handle overshoots then springs back, badge colors come from a hue f See all demos in this post
If you like this effect - similar demos
Pickers05 Tick snap — finding the nearest tick and sticking with a restoring bounceCSS Range Slider: 9 Physics-Grade Custom Sliders
Pickers03 HEX, RGB and HSL boxes that follow each otherCustom Color Picker: 9 JS Parts, No Library
Pickers03 Picker That Leaves Its Drag Path as a Sparkline Trail9 Graph Animation CSS Pickers With Live Recompute