08最小間隔を守るために弾性で押される範囲スライダー
2つのハンドルの間隔が最小値より狭くなろうとすると反対側のハンドルも一緒に押し出されて間隔を守り、画面上の位置は質量減衰スプリング物理で目標値を追いかけてわずかにオーバーシュートしながら追従する。
使いどころ間隔が狭くなりすぎてはいけない人数範囲フィルター
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="最小値" 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="最大値" 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">間隔を12以下に狭めると反対側が弾性で押される</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 最小間隔を守るために弾性で押される範囲スライダー — 計測 累積面積 3.027% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · レンジスライダー ui 9種 — ハンドルが交差しない計算まで
このデモが属する記事
レンジスライダー ui 9種 — ハンドルが交差しない計算までレンジスライダー ui 9種のうち、1つは2つのハンドルが最小間隔より内側で重ならないよう計算で防ぎ、1つは値が近づくと2つの値の吹き出しが1つに合体し、1つはトラックの奥に棒グラフを敷いて分布を一緒に示します。 この記事のデモをすべて見る


