05눈금 스냅 — 가장 가까운 눈금을 찾아 복원력으로 튕겨 붙는 단속 함수
값에서 눈금 간격의 배수를 round() 로 구해 가장 가까운 눈금을 찾고, 놓는 순간 목표까지 거리에 비례하는 복원력으로 튕겨 들어가게 하면 자석에 붙듯 눈금에 안착합니다. 스냅 직전 1~2프레임의 살짝 밀림이 '붙는다'는 감각을 만듭니다.
쓰임인원수·수량 선택, 단계 설정
pointeruprequestAnimationFrameCSS custom properties

<main class="stage">
<div class="ts" id="ts">
<p class="ts__hint">가장 가까운 눈금을 round() 로 찾아 복원력으로 튕겨 붙입니다 — 눈금 10개</p>
<div class="ts__wrap" id="tsWrap" role="img" aria-label="드래그가 가장 가까운 눈금에 자석처럼 튕겨 붙는 장면">
<div class="ts__ticks" id="tsTicks"></div>
<input class="ts__range" id="tsRange" type="range" min="0" max="100" step="1" value="50" aria-label="값 슬라이더">
</div>
<p class="ts__meta"><b class="ts__tag" id="tsTick">50</b><span class="ts__unit">가장 가까운 눈금 (간격 10)</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 눈금 스냅(05) — 눈금 점은 장식(::before 대신 JS 로 생성한 절대배치 span), 트랙 채움과
// 썸은 pseudo-element 로 직접 커스텀한다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
$track-h: 8px;
$thumb-d: 20px;
.ts {
display: flex; flex-direction: column; align-items: center; gap: $sp-3;
width: 100%; max-width: 360px;
}
.ts__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream; text-align: center;
}
.ts__wrap {
position: relative; width: 100%; height: 40px;
display: flex; align-items: center;
--pct: 50;
}
.ts__ticks {
position: absolute; left: 0; right: 0; top: 50%; height: 4px; margin-top: -2px;
pointer-events: none;
}
.ts__tick {
position: absolute; top: 0; width: 4px; height: 4px; margin-left: -2px;
border-radius: 50%; background: rgba($subject-cream, .40);
}
.ts__range {
appearance: none; -webkit-appearance: none;
width: 100%; height: $thumb-d; margin: 0; background: transparent;
position: relative; z-index: 1;
}
.ts__range:focus-visible { outline: 2px solid $subject-cream; outline-offset: 4px; border-radius: $r-pill; }
.ts__range::-webkit-slider-runnable-track {
height: $track-h; border-radius: $r-pill;
background: linear-gradient(to right, $color calc(var(--pct) * 1%), rgba($subject-cream, .16) calc(var(--pct) * 1%));
}
.ts__range::-webkit-slider-thumb {
appearance: none; -webkit-appearance: none;
width: $thumb-d; height: $thumb-d; border-radius: 50%;
background: $subject-cream; box-shadow: $shadow-press;
margin-top: calc(($track-h - $thumb-d) / 2);
}
.ts__range::-moz-range-track {
height: $track-h; border-radius: $r-pill; background: rgba($subject-cream, .16);
}
.ts__range::-moz-range-progress {
height: $track-h; border-radius: $r-pill; background: $color;
}
.ts__range::-moz-range-thumb {
width: $thumb-d; height: $thumb-d; border: 0; border-radius: 50%;
background: $subject-cream; box-shadow: $shadow-press;
}
.ts__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-cream, .82); }
.ts__tag { font-weight: 700; color: $stage-yellow; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.ts__unit { color: rgba($subject-cream, .72); }
@media (max-width: 340px) {
.ts { gap: $sp-2; max-width: 280px; }
.ts__hint { font-size: 8px; }
.ts__meta { font-size: 7px; }
}
@media (prefers-reduced-motion: reduce) {
.ts__range { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ts {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 360px;
}
.ts__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
text-align: center;
}
.ts__wrap {
position: relative;
width: 100%;
height: 40px;
display: flex;
align-items: center;
--pct: 50;
}
.ts__ticks {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 4px;
margin-top: -2px;
pointer-events: none;
}
.ts__tick {
position: absolute;
top: 0;
width: 4px;
height: 4px;
margin-left: -2px;
border-radius: 50%;
background: rgba(255, 247, 230, 0.4);
}
.ts__range {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 20px;
margin: 0;
background: transparent;
position: relative;
z-index: 1;
}
.ts__range:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 4px;
border-radius: 999px;
}
.ts__range::-webkit-slider-runnable-track {
height: 8px;
border-radius: 999px;
background: linear-gradient(to right, #2f6df6 calc(var(--pct) * 1%), rgba(255, 247, 230, 0.16) calc(var(--pct) * 1%));
}
.ts__range::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
margin-top: -6px;
}
.ts__range::-moz-range-track {
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
}
.ts__range::-moz-range-progress {
height: 8px;
border-radius: 999px;
background: #2f6df6;
}
.ts__range::-moz-range-thumb {
width: 20px;
height: 20px;
border: 0;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ts__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.82);
}
.ts__tag {
font-weight: 700;
color: #ffd23f;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.ts__unit {
color: rgba(255, 247, 230, 0.72);
}
@media (max-width: 340px) {
.ts {
gap: 8px;
max-width: 280px;
}
.ts__hint {
font-size: 8px;
}
.ts__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.ts__range {
transition: none;
}
}// 눈금 스냅(05) — 원시 드래그 목표(rawTarget)를 매 프레임 계산하고, 그 값을 눈금
// 간격(STEP=10)으로 나눈 round() 결과를 "이산 목표(tickTarget)" 로 삼는다. pos 는
// 항상 tickTarget 을 스프링(-k(pos-tick)-c·속도)으로 뒤쫓으므로, tickTarget 이 한
// 칸 건너뛸 때마다 pos 가 살짝 지나쳤다가 튕겨 붙는다. CSS @keyframes 는 없다.
(function () {
var root = document.getElementById('ts');
var wrap = document.getElementById('tsWrap');
var ticksEl = document.getElementById('tsTicks');
var range = document.getElementById('tsRange');
var tickEl = document.getElementById('tsTick');
if (!root || !wrap || !ticksEl || !range || !tickEl) return;
var MIN = 0, MAX = 100;
var STEP = (MAX - MIN) / 10; // 변형: max/20 이면 STEP = (MAX-MIN)/20
var K = 260, C = 22; // 스냅 스프링 상수 · 감쇠 (오버슈트 약 5%)
var TAU = 0.16;
for (var i = 0; i <= 10; i++) {
var d = document.createElement('span');
d.className = 'ts__tick';
d.style.left = (i * 10) + '%';
ticksEl.appendChild(d);
}
var rawTarget = 50;
var pos = 50, vel = 0;
function paint() {
var pct = (pos - MIN) / (MAX - MIN) * 100;
wrap.style.setProperty('--pct', pct.toFixed(2));
var tick = Math.round(rawTarget / STEP) * STEP;
tickEl.textContent = String(Math.round(tick));
range.setAttribute('aria-valuenow', String(Math.round(pos)));
}
paint();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
function stop() { playing = false; }
range.addEventListener('pointerdown', stop);
range.addEventListener('keydown', stop);
range.addEventListener('input', function () {
rawTarget = Number(range.value);
pos = Math.round(rawTarget / STEP) * STEP;
vel = 0;
range.value = String(Math.round(pos));
});
var segTargets = [50, 8, 77, 24, 96, 50];
var seg = 0, segFrom = rawTarget;
var SEG_MS = 380;
var segStart = performance.now();
var last = segStart;
if (reduce) {
rawTarget = segTargets[0];
pos = Math.round(rawTarget / STEP) * STEP;
range.value = String(pos);
paint();
} else {
(function step(now) {
var dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (playing) {
var elapsed = now - segStart;
if (elapsed >= SEG_MS) {
seg = (seg + 1) % segTargets.length;
segStart = now; elapsed = 0; segFrom = rawTarget;
}
var target = segTargets[seg];
var t = elapsed / 1000;
var k = 1 - Math.exp(-t / TAU);
rawTarget = segFrom + (target - segFrom) * k;
}
var tickTarget = Math.round(rawTarget / STEP) * STEP;
var acc = -K * (pos - tickTarget) - C * vel;
vel += acc * dt;
pos += vel * dt;
range.value = String(Math.round(pos));
paint();
requestAnimationFrame(step);
})(last);
}
})();// RangeSliderTickSnapBounce — vanilla 판(run/334/demo/05-range-slider-tick-snap-bounce/index.html)
// 의 React 포트. 원시 드래그 목표(rawTarget)를 매 프레임 계산하고, 그 값을 눈금 간격(STEP=10)
// 으로 나눈 round() 결과를 "이산 목표(tickTarget)" 로 삼는다. pos 는 항상 tickTarget 을
// 스프링(-k(pos-tick)-c·속도)으로 뒤쫓으므로, tickTarget 이 한 칸 건너뛸 때마다 pos 가
// 살짝 지나쳤다가 튕겨 붙는다. CSS @keyframes 는 없다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RangeSliderTickSnapBounce.module.scss";
export type RangeSliderTickSnapBounceProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const HINT = "가장 가까운 눈금을 round() 로 찾아 복원력으로 튕겨 붙입니다 — 눈금 10개";
const ARIA_SCENE = "드래그가 가장 가까운 눈금에 자석처럼 튕겨 붙는 장면";
const RANGE_LABEL = "값 슬라이더";
const UNIT = "가장 가까운 눈금 (간격 10)";
const MIN = 0, MAX = 100;
const STEP = (MAX - MIN) / 10; // 변형: max/20 이면 STEP = (MAX-MIN)/20
const K = 260, C = 22; // 스냅 스프링 상수 · 감쇠 (오버슈트 약 5%)
const TAU = 0.16;
const SEG_TARGETS = [50, 8, 77, 24, 96, 50];
const SEG_MS = 380;
export default function RangeSliderTickSnapBounce({
duration = "220ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6"
}: RangeSliderTickSnapBounceProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const ticksRef = useRef<HTMLDivElement | null>(null);
const rangeRef = useRef<HTMLInputElement | null>(null);
const tickRef = useRef<HTMLElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const wrap = wrapRef.current;
const ticksEl = ticksRef.current;
const range = rangeRef.current;
const tickEl = tickRef.current;
if (!wrap || !ticksEl || !range || !tickEl) return;
for (let i = 0; i <= 10; i++) {
const d = document.createElement("span");
d.className = styles.tick;
d.style.left = i * 10 + "%";
ticksEl.appendChild(d);
}
let rawTarget = 50;
let pos = 50, vel = 0;
function paint(): void {
const pct = ((pos - MIN) / (MAX - MIN)) * 100;
wrap!.style.setProperty("--pct", pct.toFixed(2));
const tick = Math.round(rawTarget / STEP) * STEP;
tickEl!.textContent = String(Math.round(tick));
range!.setAttribute("aria-valuenow", String(Math.round(pos)));
}
paint();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
function stop(): void { playing = false; }
const onInput = (): void => {
rawTarget = Number(range!.value);
pos = Math.round(rawTarget / STEP) * STEP;
vel = 0;
range!.value = String(Math.round(pos));
};
range.addEventListener("pointerdown", stop);
range.addEventListener("keydown", stop);
range.addEventListener("input", onInput);
let seg = 0, segFrom = rawTarget;
let segStart = performance.now();
let last = segStart;
if (reduce) {
rawTarget = SEG_TARGETS[0];
pos = Math.round(rawTarget / STEP) * STEP;
range.value = String(pos);
paint();
} else {
const step = (now: number): void => {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (playing) {
let elapsed = now - segStart;
if (elapsed >= SEG_MS) {
seg = (seg + 1) % SEG_TARGETS.length;
segStart = now; elapsed = 0; segFrom = rawTarget;
}
const target = SEG_TARGETS[seg];
const t = elapsed / 1000;
const k = 1 - Math.exp(-t / TAU);
rawTarget = segFrom + (target - segFrom) * k;
}
const tickTarget = Math.round(rawTarget / STEP) * STEP;
const acc = -K * (pos - tickTarget) - C * vel;
vel += acc * dt;
pos += vel * dt;
range!.value = String(Math.round(pos));
paint();
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(rafRef.current);
range.removeEventListener("pointerdown", stop);
range.removeEventListener("keydown", stop);
range.removeEventListener("input", onInput);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div
className={styles.ts}
style={{ "--duration": duration, "--easing": easing, "--color": color } as Vars}
>
<p className={styles.hint}>{HINT}</p>
<div className={styles.wrap} ref={wrapRef} role="img" aria-label={ARIA_SCENE}>
<div className={styles.ticks} ref={ticksRef}></div>
<input
className={styles.range}
ref={rangeRef}
type="range"
min={0}
max={100}
step={1}
defaultValue={50}
aria-label={RANGE_LABEL}
/>
</div>
<p className={styles.meta}>
<b className={styles.tag} ref={tickRef}>50</b>
<span className={styles.unit}>{UNIT}</span>
</p>
</div>
</main>
);
}05 눈금 스냅 — 가장 가까운 눈금을 찾아 복원력으로 튕겨 붙는 단속 함수 — 측정 누적면적 3.133% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · css input range 슬라이더 — 관성·스냅 계산을 얹은 커스텀 9종
이 데모가 속한 글
css input range 슬라이더 — 관성·스냅 계산을 얹은 커스텀 9종드래그를 놓으면 속도값으로 관성 lerp가 감쇠하고, 값이 바뀔 때마다 스프링 오버슈트로 썸이 살짝 튕겨 안착합니다. 눈금 스냅·세로 볼륨형·색 그라디언트 트랙까지 — css input range 슬라이더 9종, 코 이 글의 데모 전부 보기


