02스프링 오버슈트 — 값이 바뀔 때 썸이 살짝 튕겨 안착하는 감쇠 진동
썸의 스케일을 스프링-감쇠 미분방정식(가속도 = -k(목표-현재) - c*속도)으로 requestAnimationFrame 안에서 적분하면, 눌림에서 놓일 때 목표를 살짝 지나쳤다가 되돌아오는 자연스러운 튕김이 나옵니다. 계수 k·c 두 값만으로 탄탄함을 조율합니다.
쓰임오디오 플레이어, 편집 도구
requestAnimationFrameCSS custom propertiestransform: scale
<main class="stage">
<div class="so" id="so">
<p class="so__hint">값이 바뀔 때마다 스프링-감쇠 방정식으로 썸이 살짝 튕겼다가 안착합니다</p>
<div class="so__wrap" id="soWrap" role="img" aria-label="값이 바뀔 때마다 썸이 튕겨 안착하는 스프링 오버슈트 장면">
<input class="so__range" id="soRange" type="range" min="0" max="100" step="1" value="50" aria-label="값 슬라이더">
</div>
<p class="so__meta"><b class="so__tag" id="soScale">1.000</b><span class="so__unit">스케일 = -k(목표-현재) - c·속도 적분</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스프링 오버슈트(02) — 썸 스케일은 JS 스프링 적분값을 --scale 로 받아
// ::-webkit-slider-thumb 에 transform: scale() 로만 얹는다. 위치·스케일 모두 rAF 계산.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
$track-h: 8px;
$thumb-d: 22px;
.so {
display: flex; flex-direction: column; align-items: center; gap: $sp-3;
width: 100%; max-width: 360px;
}
.so__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink; text-align: center;
}
.so__wrap {
position: relative; width: 100%; height: 40px;
display: flex; align-items: center;
--pct: 50; --scale: 1;
}
.so__range {
appearance: none; -webkit-appearance: none;
width: 100%; height: $thumb-d; margin: 0; background: transparent;
}
.so__range:focus-visible { outline: 2px solid $subject-ink; outline-offset: 4px; border-radius: $r-pill; }
.so__range::-webkit-slider-runnable-track {
height: $track-h; border-radius: $r-pill;
background: linear-gradient(to right, $color calc(var(--pct) * 1%), rgba($subject-ink, .16) calc(var(--pct) * 1%));
}
.so__range::-webkit-slider-thumb {
appearance: none; -webkit-appearance: none;
width: $thumb-d; height: $thumb-d; border-radius: 50%;
background: $subject-ink; box-shadow: $shadow-press;
margin-top: calc(($track-h - $thumb-d) / 2);
transform: scale(var(--scale, 1));
transform-origin: center center;
}
.so__range::-moz-range-track {
height: $track-h; border-radius: $r-pill; background: rgba($subject-ink, .16);
}
.so__range::-moz-range-progress {
height: $track-h; border-radius: $r-pill; background: $color;
}
.so__range::-moz-range-thumb {
width: $thumb-d; height: $thumb-d; border: 0; border-radius: 50%;
background: $subject-ink; box-shadow: $shadow-press;
transform: scale(var(--scale, 1));
transform-origin: center center;
}
.so__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .82); }
.so__tag { font-weight: 700; color: $subject-blue-deep; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.so__unit { color: rgba($subject-ink, .72); }
@media (max-width: 340px) {
.so { gap: $sp-2; max-width: 280px; }
.so__hint { font-size: 8px; }
.so__meta { font-size: 7px; }
}
@media (prefers-reduced-motion: reduce) {
.so__range::-webkit-slider-thumb { transform: scale(1); }
.so__range::-moz-range-thumb { transform: scale(1); }
}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);
}
.so {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 360px;
}
.so__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
text-align: center;
}
.so__wrap {
position: relative;
width: 100%;
height: 40px;
display: flex;
align-items: center;
--pct: 50;
--scale: 1;
}
.so__range {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 22px;
margin: 0;
background: transparent;
}
.so__range:focus-visible {
outline: 2px solid #17141a;
outline-offset: 4px;
border-radius: 999px;
}
.so__range::-webkit-slider-runnable-track {
height: 8px;
border-radius: 999px;
background: linear-gradient(to right, #2f6df6 calc(var(--pct) * 1%), rgba(23, 20, 26, 0.16) calc(var(--pct) * 1%));
}
.so__range::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 22px;
height: 22px;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
margin-top: -7px;
transform: scale(var(--scale, 1));
transform-origin: center center;
}
.so__range::-moz-range-track {
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.16);
}
.so__range::-moz-range-progress {
height: 8px;
border-radius: 999px;
background: #2f6df6;
}
.so__range::-moz-range-thumb {
width: 22px;
height: 22px;
border: 0;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: scale(var(--scale, 1));
transform-origin: center center;
}
.so__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.82);
}
.so__tag {
font-weight: 700;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.so__unit {
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.so {
gap: 8px;
max-width: 280px;
}
.so__hint {
font-size: 8px;
}
.so__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.so__range::-webkit-slider-thumb {
transform: scale(1);
}
.so__range::-moz-range-thumb {
transform: scale(1);
}
}// 스프링 오버슈트(02) — 값이 바뀌는 순간 썸 스케일에 속도 킥을 넣고, 매 프레임
// accel = -k*(scale-1) - c*velocity 를 적분해 감쇠 진동을 만든다. k·c 두 계수만으로
// 튕김의 세기와 빠르기가 정해진다. CSS @keyframes·transition 은 없다 — 위치와 스케일
// 둘 다 requestAnimationFrame 이 매 프레임 다시 계산해 그린다.
(function () {
var root = document.getElementById('so');
var wrap = document.getElementById('soWrap');
var range = document.getElementById('soRange');
var scaleEl = document.getElementById('soScale');
if (!root || !wrap || !range || !scaleEl) return;
var MIN = 0, MAX = 100;
var K = 170, C = 14; // 스프링 상수 · 감쇠 계수 (임계감쇠비 약 0.54 — 12~14% 오버슈트)
var KICK = 9; // 값이 바뀔 때 스케일 속도에 주는 충격량
var pos = 50; // 트랙 위 표시 값(0~100)
var scale = 1;
var vel = 0;
function paint() {
var pct = (pos - MIN) / (MAX - MIN) * 100;
wrap.style.setProperty('--pct', pct.toFixed(2));
wrap.style.setProperty('--scale', scale.toFixed(4));
range.setAttribute('aria-valuenow', String(Math.round(pos)));
scaleEl.textContent = scale.toFixed(3);
}
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 () {
pos = Number(range.value);
vel += KICK;
paint();
});
var targets = [50, 15, 85, 40, 95, 50];
var seg = 0, posFrom = pos;
var SEG_MS = 400;
var segStart = performance.now();
var last = segStart;
if (reduce) {
pos = targets[0];
range.value = String(pos);
scale = 1; vel = 0;
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) % targets.length;
segStart = now; elapsed = 0; posFrom = pos;
vel += KICK; // 목표가 바뀔 때마다 튕김 킥
}
var target = targets[seg];
var t = elapsed / SEG_MS;
pos = posFrom + (target - posFrom) * t;
range.value = String(Math.round(pos));
}
var acc = -K * (scale - 1) - C * vel;
vel += acc * dt;
scale += vel * dt;
paint();
requestAnimationFrame(step);
})(segStart);
}
})();// RangeSliderSpringThumbOvershoot — vanilla 판(run/334/demo/02-range-slider-spring-thumb-overshoot/index.html)
// 의 React 포트. 값이 바뀌는 순간 썸 스케일에 속도 킥을 넣고, 매 프레임
// accel = -k*(scale-1) - c*velocity 를 적분해 감쇠 진동을 만든다. CSS @keyframes·transition 은
// 없다 — 위치와 스케일 둘 다 requestAnimationFrame 이 매 프레임 다시 계산해 그린다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RangeSliderSpringThumbOvershoot.module.scss";
export type RangeSliderSpringThumbOvershootProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const HINT = "값이 바뀔 때마다 스프링-감쇠 방정식으로 썸이 살짝 튕겼다가 안착합니다";
const ARIA_SCENE = "값이 바뀔 때마다 썸이 튕겨 안착하는 스프링 오버슈트 장면";
const ARIA_RANGE = "값 슬라이더";
const UNIT = "스케일 = -k(목표-현재) - c·속도 적분";
const MIN = 0, MAX = 100;
const K = 170, C = 14; // 스프링 상수 · 감쇠 계수 (임계감쇠비 약 0.54 — 12~14% 오버슈트)
const KICK = 9; // 값이 바뀔 때 스케일 속도에 주는 충격량
const TARGETS = [50, 15, 85, 40, 95, 50];
const SEG_MS = 400;
export default function RangeSliderSpringThumbOvershoot({
duration = "220ms",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6"
}: RangeSliderSpringThumbOvershootProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const rangeRef = useRef<HTMLInputElement | null>(null);
const scaleRef = useRef<HTMLElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const wrap = wrapRef.current;
const range = rangeRef.current;
const scaleEl = scaleRef.current;
if (!wrap || !range || !scaleEl) return;
let pos = 50; // 트랙 위 표시 값(0~100)
let scale = 1;
let vel = 0;
function paint(): void {
const pct = ((pos - MIN) / (MAX - MIN)) * 100;
wrap!.style.setProperty("--pct", pct.toFixed(2));
wrap!.style.setProperty("--scale", scale.toFixed(4));
range!.setAttribute("aria-valuenow", String(Math.round(pos)));
scaleEl!.textContent = scale.toFixed(3);
}
paint();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
function stop(): void {
playing = false;
}
function onInput(): void {
pos = Number(range!.value);
vel += KICK;
paint();
}
range.addEventListener("pointerdown", stop);
range.addEventListener("keydown", stop);
range.addEventListener("input", onInput);
if (reduce) {
pos = TARGETS[0];
range.value = String(pos);
scale = 1;
vel = 0;
paint();
} else {
let seg = 0;
let posFrom = pos;
let segStart = performance.now();
let last = segStart;
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) % TARGETS.length;
segStart = now;
elapsed = 0;
posFrom = pos;
vel += KICK; // 목표가 바뀔 때마다 튕김 킥
}
const target = TARGETS[seg];
const t = elapsed / SEG_MS;
pos = posFrom + (target - posFrom) * t;
range!.value = String(Math.round(pos));
}
const acc = -K * (scale - 1) - C * vel;
vel += acc * dt;
scale += vel * dt;
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.so}
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}>
<input
className={styles.range}
ref={rangeRef}
type="range"
min={0}
max={100}
step={1}
defaultValue={50}
aria-label={ARIA_RANGE}
/>
</div>
<p className={styles.meta}>
<b className={styles.tag} ref={scaleRef}>1.000</b>
<span className={styles.unit}>{UNIT}</span>
</p>
</div>
</main>
);
}02 스프링 오버슈트 — 값이 바뀔 때 썸이 살짝 튕겨 안착하는 감쇠 진동 — 측정 누적면적 6.238% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · css input range 슬라이더 — 관성·스냅 계산을 얹은 커스텀 9종
이 데모가 속한 글
css input range 슬라이더 — 관성·스냅 계산을 얹은 커스텀 9종드래그를 놓으면 속도값으로 관성 lerp가 감쇠하고, 값이 바뀔 때마다 스프링 오버슈트로 썸이 살짝 튕겨 안착합니다. 눈금 스냅·세로 볼륨형·색 그라디언트 트랙까지 — css input range 슬라이더 9종, 코 이 글의 데모 전부 보기

