08Range Slider That Springs to Keep Its Minimum Gap
If the gap between the two handles would shrink past its minimum, the opposite handle is pushed along to preserve it, and each handle's on-screen position chases its target with spring physics, overshooting slightly as it settles.
Best forA headcount range filter where the gap must never get too narrow
MIN_GAP=12requestAnimationFramerole=slider

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__track" id="track">
<div class="ds__fill" id="fill"></div>
<button type="button" class="ds__thumb ds__thumb--lo" id="thumbLo" role="slider"
aria-label="Minimum value" aria-valuemin="0" aria-valuemax="100" aria-valuenow="20" aria-valuetext="20" tabindex="0"></button>
<button type="button" class="ds__thumb ds__thumb--hi" id="thumbHi" role="slider"
aria-label="Maximum value" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" aria-valuetext="70" tabindex="0"></button>
</div>
<p class="ds__readout"><span id="loOut">20</span> – <span id="hiOut">70</span></p>
<p class="ds__caption">Narrowing the gap past 12 pushes the opposite handle elastically</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개 — 스프링 물리는 JS 상수(K, DAMP)로 관리해 CSS 트랜지션은 쓰지 않는다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-pink;
// 계산 겹: 목표값은 즉시 갱신되지만 화면 위치는 질량-감쇠 스프링으로 뒤쫓아 오버슈트한다 (rAF).
@include stage($stage-ink);
.ds { width: min(280px, 84vw); color: $subject-cream; text-align: center; }
.ds__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-cream, .16); margin: $sp-10 0 $sp-4;
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 20%; width: 50%;
background: $color; border-radius: $r-pill;
}
.ds__thumb {
position: absolute; top: 50%; left: 20%; width: 18px; height: 18px;
appearance: none; border: 0; border-radius: 50%; background: $subject-cream;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%); z-index: 2;
}
.ds__thumb--hi { left: 70%; }
.ds__thumb:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ds__thumb:active { cursor: grabbing; }
.ds__readout { margin: 0; font-weight: 700; font-size: 18px; line-height: 1.2; font-variant-numeric: tabular-nums; }
.ds__caption { margin: $sp-1 0 0; font-weight: 600; font-size: 11px; color: rgba($subject-cream, .72); }
@media (max-width: 340px) {
.ds { width: min(240px, 80vw); }
.ds__readout { font-size: 16px; }
.ds__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ds__thumb, .ds__fill { 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);
}
.ds {
width: min(280px, 84vw);
color: #fff7e6;
text-align: center;
}
.ds__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
margin: 40px 0 16px;
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 20%;
width: 50%;
background: #ff9ebb;
border-radius: 999px;
}
.ds__thumb {
position: absolute;
top: 50%;
left: 20%;
width: 18px;
height: 18px;
appearance: none;
border: 0;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
z-index: 2;
}
.ds__thumb--hi {
left: 70%;
}
.ds__thumb:focus-visible {
outline: 2px solid #ff9ebb;
outline-offset: 2px;
}
.ds__thumb:active {
cursor: grabbing;
}
.ds__readout {
margin: 0;
font-weight: 700;
font-size: 18px;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.ds__caption {
margin: 4px 0 0;
font-weight: 600;
font-size: 11px;
color: rgba(255, 247, 230, 0.72);
}
@media (max-width: 340px) {
.ds {
width: min(240px, 80vw);
}
.ds__readout {
font-size: 16px;
}
.ds__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ds__thumb, .ds__fill {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100, MIN_GAP = 12, K = 210, DAMP = 18;
var root = document.getElementById('ds');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var lo = document.getElementById('thumbLo');
var hi = document.getElementById('thumbHi');
var loOut = document.getElementById('loOut');
var hiOut = document.getElementById('hiOut');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var target = { lo: 20, hi: 70 };
var pos = { lo: { v: 20, vel: 0 }, hi: { v: 70, vel: 0 } };
function paintAria() {
lo.setAttribute('aria-valuenow', String(target.lo)); hi.setAttribute('aria-valuenow', String(target.hi));
lo.setAttribute('aria-valuetext', String(target.lo)); hi.setAttribute('aria-valuetext', String(target.hi));
loOut.textContent = String(target.lo); hiOut.textContent = String(target.hi);
}
function paintVisual() {
lo.style.left = pos.lo.v + '%'; hi.style.left = pos.hi.v + '%';
fill.style.left = pos.lo.v + '%'; fill.style.width = Math.max(0, pos.hi.v - pos.lo.v) + '%';
}
// 계산 겹: 간격이 MIN_GAP 아래로 좁혀지면 반대쪽 목표값을 함께 밀어내고, 시각 위치는 스프링(질량-감쇠) 물리로 목표를 뒤쫓아 오버슈트한다.
function syncReduced() {
if (!reduce) return;
pos.lo.v = target.lo; pos.hi.v = target.hi; paintVisual();
}
function setLo(n) {
var nlo = Math.max(MIN, Math.min(n, MAX));
if (nlo > target.hi - MIN_GAP) {
target.hi = Math.min(MAX, nlo + MIN_GAP);
nlo = Math.min(nlo, target.hi - MIN_GAP);
}
target.lo = nlo; paintAria(); syncReduced();
}
function setHi(n) {
var nhi = Math.min(MAX, Math.max(n, MIN));
if (nhi < target.lo + MIN_GAP) {
target.lo = Math.max(MIN, nhi - MIN_GAP);
nhi = Math.max(nhi, target.lo + MIN_GAP);
}
target.hi = nhi; paintAria(); syncReduced();
}
function pctFromX(x) {
var r = track.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function human() { root.classList.remove('is-demo'); clearInterval(timer); }
function drag(thumb, setter, isLo) {
thumb.addEventListener('pointerdown', function (e) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
});
thumb.addEventListener('keydown', function (e) {
var step = { ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 }[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? target.lo : target.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : target.lo + MIN_GAP); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? target.hi - MIN_GAP : MAX); }
});
}
drag(lo, setLo, true); drag(hi, setHi, false);
var last = null;
function springFrame(ts) {
if (reduce) { pos.lo.v = target.lo; pos.hi.v = target.hi; paintVisual(); return; }
if (last === null) last = ts;
var dt = Math.min(0.032, (ts - last) / 1000); last = ts;
['lo', 'hi'].forEach(function (k) {
var s = pos[k], t = target[k];
var force = -K * (s.v - t) - DAMP * s.vel;
s.vel += force * dt; s.v += s.vel * dt;
});
paintVisual();
requestAnimationFrame(springFrame);
}
if (!reduce) requestAnimationFrame(springFrame);
var seq = [
{ who: 'lo', to: 62 },
{ who: 'hi', to: 25 },
{ who: 'lo', to: 55 },
{ who: 'hi', to: 15 },
{ who: 'lo', to: 20 }
];
var si = 0;
function tick() {
var step = seq[si % seq.length]; si++;
if (step.who === 'lo') setLo(step.to); else setHi(step.to);
}
var timer = reduce ? null : setInterval(tick, 480);
paintAria(); paintVisual();
}());import { useEffect, useRef } from 'react';
import styles from './DualSliderMinGapElasticPush.module.scss';
const MIN = 0;
const MAX = 100;
const MIN_GAP = 12;
const K = 210;
const DAMP = 18;
const TICK_MS = 480;
const SEQ: { who: 'lo' | 'hi'; to: number }[] = [
{ who: 'lo', to: 62 },
{ who: 'hi', to: 25 },
{ who: 'lo', to: 55 },
{ who: 'hi', to: 15 },
{ who: 'lo', to: 20 }
];
export default function DualSliderMinGapElasticPush() {
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const loRef = useRef<HTMLButtonElement>(null);
const hiRef = useRef<HTMLButtonElement>(null);
const loOutRef = useRef<HTMLSpanElement>(null);
const hiOutRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const track = trackRef.current;
const fill = fillRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const loOut = loOutRef.current;
const hiOut = hiOutRef.current;
if (!track || !fill || !lo || !hi || !loOut || !hiOut) return undefined;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const target = { lo: 20, hi: 70 };
const pos = { lo: { v: 20, vel: 0 }, hi: { v: 70, vel: 0 } };
function paintAria() {
lo!.setAttribute('aria-valuenow', String(target.lo)); hi!.setAttribute('aria-valuenow', String(target.hi));
lo!.setAttribute('aria-valuetext', String(target.lo)); hi!.setAttribute('aria-valuetext', String(target.hi));
loOut!.textContent = String(target.lo); hiOut!.textContent = String(target.hi);
}
function paintVisual() {
lo!.style.left = pos.lo.v + '%'; hi!.style.left = pos.hi.v + '%';
fill!.style.left = pos.lo.v + '%'; fill!.style.width = Math.max(0, pos.hi.v - pos.lo.v) + '%';
}
function syncReduced() {
if (!reduce) return;
pos.lo.v = target.lo; pos.hi.v = target.hi; paintVisual();
}
// 계산 겹: 간격이 MIN_GAP 아래로 좁혀지면 반대쪽 목표값을 함께 밀어내고, 시각 위치는 스프링(질량-감쇠) 물리로 목표를 뒤쫓아 오버슈트한다.
function setLo(n: number) {
let nlo = Math.max(MIN, Math.min(n, MAX));
if (nlo > target.hi - MIN_GAP) {
target.hi = Math.min(MAX, nlo + MIN_GAP);
nlo = Math.min(nlo, target.hi - MIN_GAP);
}
target.lo = nlo; paintAria(); syncReduced();
}
function setHi(n: number) {
let nhi = Math.min(MAX, Math.max(n, MIN));
if (nhi < target.lo + MIN_GAP) {
target.lo = Math.max(MIN, nhi - MIN_GAP);
nhi = Math.max(nhi, target.lo + MIN_GAP);
}
target.hi = nhi; paintAria(); syncReduced();
}
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
let timer = 0;
function human() { window.clearInterval(timer); }
function bindDrag(thumb: HTMLButtonElement, setter: (n: number) => void, isLo: boolean) {
function onDown(e: PointerEvent) {
human(); thumb.setPointerCapture(e.pointerId);
function move(ev: PointerEvent) { setter(pctFromX(ev.clientX)); }
function up() { thumb.removeEventListener('pointermove', move); thumb.removeEventListener('pointerup', up); }
thumb.addEventListener('pointermove', move); thumb.addEventListener('pointerup', up);
}
function onKey(e: KeyboardEvent) {
const step = ({ ArrowRight: 1, ArrowLeft: -1, ArrowUp: 1, ArrowDown: -1 } as Record<string, number>)[e.key];
if (step !== undefined) { e.preventDefault(); human(); setter((isLo ? target.lo : target.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : target.lo + MIN_GAP); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? target.hi - MIN_GAP : MAX); }
}
thumb.addEventListener('pointerdown', onDown);
thumb.addEventListener('keydown', onKey);
return () => { thumb.removeEventListener('pointerdown', onDown); thumb.removeEventListener('keydown', onKey); };
}
const offLo = bindDrag(lo, setLo, true);
const offHi = bindDrag(hi, setHi, false);
let last: number | null = null;
let rafId = 0;
let stopped = false;
function springFrame(ts: number) {
if (stopped) return;
if (reduce) { pos.lo.v = target.lo; pos.hi.v = target.hi; paintVisual(); return; }
if (last === null) last = ts;
const dt = Math.min(0.032, (ts - last) / 1000); last = ts;
(['lo', 'hi'] as const).forEach((k) => {
const s = pos[k]; const t = target[k];
const force = -K * (s.v - t) - DAMP * s.vel;
s.vel += force * dt; s.v += s.vel * dt;
});
paintVisual();
rafId = requestAnimationFrame(springFrame);
}
if (!reduce) rafId = requestAnimationFrame(springFrame);
let si = 0;
function tick() {
const step = SEQ[si % SEQ.length]; si++;
if (step.who === 'lo') setLo(step.to); else setHi(step.to);
}
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paintAria(); paintVisual();
return () => { offLo(); offHi(); stopped = true; cancelAnimationFrame(rafId); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.ds} id="ds">
<div className={styles.dsTrack} ref={trackRef}>
<div className={styles.dsFill} ref={fillRef} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbLo} ref={loRef} role="slider"
aria-label="최소값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={20} aria-valuetext="20" tabIndex={0} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiRef} role="slider"
aria-label="최대값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={70} aria-valuetext="70" tabIndex={0} />
</div>
<p className={styles.dsReadout}><span ref={loOutRef}>20</span> – <span ref={hiOutRef}>70</span></p>
<p className={styles.dsCaption}>간격 12 이하로 좁히면 반대쪽이 탄성으로 밀린다</p>
</div>
</main>
);
}08 Range Slider That Springs to Keep Its Minimum Gap — Measured area 3.027% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · Dual Range Slider UI — 9 Two-Handle Patterns
The post this demo lives in
Dual Range Slider UI — 9 Two-Handle PatternsAmong nine dual range slider patterns, one blocks its two handles from crossing, one merges their value bubbles when clo See all demos in this post
If you like this effect - similar demos
Inputs03 Limit bounce — a stepper value that overshoots its min or max slightly before settling along a spring curveNumber Stepper UI: 9 Interaction Patterns
Inputs04 Quantity that springs back when it passes the stock limitCheckout Page Design — 9 Cart Patterns, No Library
Inputs02 Debounce shimmer — gathering input for 300ms while the list shimmers as a waiting stateAutocomplete Input: 9 Search Dropdowns