04Range Slider Whose Middle Drag Shifts Both Handles Together
Grabbing the middle of the filled stretch moves both handles together while keeping the gap between them fixed, stopping the moment either handle reaches the track's end.
Best forA schedule filter for shifting a chosen span forward or back while keeping its width
shiftBoth()getBoundingClientRectrole=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="10" aria-valuetext="10" 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="40" aria-valuetext="40" tabindex="0"></button>
</div>
<p class="ds__readout"><span id="loOut">10</span> – <span id="hiOut">40</span></p>
<p class="ds__caption">Drag the middle of the span to move it while keeping its width</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-lilac;
// 계산 겹: shiftBoth()/fill 드래그 핸들러가 폭(hi-lo)을 상수로 고정한 채 두 값을 같은 delta 만큼
// 평행 이동시키고, MIN·MAX 끝단에서 그 이상 못 가게 clamp 한다.
@include stage($stage-paper);
.ds { width: min(280px, 84vw); color: $subject-ink; text-align: center; }
.ds__track {
position: relative; height: 10px; border-radius: $r-pill;
background: rgba($subject-ink, .1); margin: $sp-10 0 $sp-4;
border: 1px solid rgba($subject-ink, .12);
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 10%; width: 30%;
background: $color; border-radius: $r-pill; cursor: grab; touch-action: none;
}
.ds__fill:active { cursor: grabbing; }
.ds__thumb {
position: absolute; top: 50%; left: 10%; width: 18px; height: 18px;
appearance: none; border: 2px solid $color; 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: 40%; }
.ds__thumb:focus-visible { outline: 2px solid $subject-blue-deep; 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-ink, .68); }
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left $duration $easing, width $duration $easing;
}
@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.is-demo .ds__thumb, .ds.is-demo .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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ds {
width: min(280px, 84vw);
color: #17141a;
text-align: center;
}
.ds__track {
position: relative;
height: 10px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
margin: 40px 0 16px;
border: 1px solid rgba(23, 20, 26, 0.12);
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 10%;
width: 30%;
background: #b9a0ff;
border-radius: 999px;
cursor: grab;
touch-action: none;
}
.ds__fill:active {
cursor: grabbing;
}
.ds__thumb {
position: absolute;
top: 50%;
left: 10%;
width: 18px;
height: 18px;
appearance: none;
border: 2px solid #b9a0ff;
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: 40%;
}
.ds__thumb:focus-visible {
outline: 2px solid #0a4fe8;
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(23, 20, 26, 0.68);
}
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill {
transition: left 2s ease-out, width 2s ease-out;
}
@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.is-demo .ds__thumb, .ds.is-demo .ds__fill {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100;
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 v = { lo: 10, hi: 40 };
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
lo.setAttribute('aria-valuenow', String(v.lo)); hi.setAttribute('aria-valuenow', String(v.hi));
lo.setAttribute('aria-valuetext', String(v.lo)); hi.setAttribute('aria-valuetext', String(v.hi));
loOut.textContent = String(v.lo); hiOut.textContent = String(v.hi);
}
function setLo(n) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
// 계산 겹: 폭(hi-lo)은 그대로 두고 두 값을 같은 delta 만큼 평행 이동시키되, 끝단에 닿으면 그 자리에서 멈춘다.
function shiftBoth(deltaPct) {
var w = v.hi - v.lo;
var nlo = v.lo + deltaPct;
nlo = Math.max(MIN, Math.min(nlo, MAX - w));
v.lo = nlo; v.hi = nlo + w;
paint();
}
function pctFromX(x) {
var r = track.getBoundingClientRect();
return ((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(); e.stopPropagation(); thumb.setPointerCapture(e.pointerId);
function move(ev) { setter(Math.round(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 ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : MAX); }
});
}
drag(lo, setLo, true); drag(hi, setHi, false);
fill.addEventListener('pointerdown', function (e) {
human(); fill.setPointerCapture(e.pointerId);
var startX = e.clientX;
var r = track.getBoundingClientRect();
function move(ev) {
var deltaPct = (ev.clientX - startX) / r.width * 100;
shiftBoth(deltaPct);
startX = ev.clientX;
}
function up() { fill.removeEventListener('pointermove', move); fill.removeEventListener('pointerup', up); }
fill.addEventListener('pointermove', move); fill.addEventListener('pointerup', up);
});
var centers = [25, 70, 45, 85, 15];
var width = v.hi - v.lo;
var pi = 0;
function tick() {
var c = centers[pi % centers.length]; pi++;
var target = c - width / 2;
shiftBoth(target - v.lo);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var timer = reduce ? null : setInterval(tick, 480);
paint();
}());import { useEffect, useRef } from 'react';
import styles from './DualSliderDragBothHandlesShift.module.scss';
const MIN = 0;
const MAX = 100;
const TICK_MS = 480;
const CENTERS = [25, 70, 45, 85, 15];
export default function DualSliderDragBothHandlesShift() {
const rootRef = useRef<HTMLDivElement>(null);
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 root = rootRef.current;
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 (!root || !track || !fill || !lo || !hi || !loOut || !hiOut) return undefined;
const v = { lo: 10, hi: 40 };
function paint() {
lo!.style.left = v.lo + '%'; hi!.style.left = v.hi + '%';
fill!.style.left = v.lo + '%'; fill!.style.width = (v.hi - v.lo) + '%';
lo!.setAttribute('aria-valuenow', String(v.lo)); hi!.setAttribute('aria-valuenow', String(v.hi));
lo!.setAttribute('aria-valuetext', String(v.lo)); hi!.setAttribute('aria-valuetext', String(v.hi));
loOut!.textContent = String(v.lo); hiOut!.textContent = String(v.hi);
}
function setLo(n: number) { v.lo = Math.max(MIN, Math.min(n, v.hi)); paint(); }
function setHi(n: number) { v.hi = Math.min(MAX, Math.max(n, v.lo)); paint(); }
// 계산 겹: 폭(hi-lo)은 그대로 두고 두 값을 같은 delta 만큼 평행 이동시키되, 끝단에 닿으면 그 자리에서 멈춘다.
function shiftBoth(deltaPct: number) {
const w = v.hi - v.lo;
let nlo = v.lo + deltaPct;
nlo = Math.max(MIN, Math.min(nlo, MAX - w));
v.lo = nlo; v.hi = nlo + w;
paint();
}
function pctFromX(x: number) {
const r = track!.getBoundingClientRect();
return ((x - r.left) / r.width) * 100;
}
let timer = 0;
function human() { root!.classList.remove(styles['is-demo']); window.clearInterval(timer); }
function bindDrag(thumb: HTMLButtonElement, setter: (n: number) => void, isLo: boolean) {
function onDown(e: PointerEvent) {
human(); e.stopPropagation(); thumb.setPointerCapture(e.pointerId);
function move(ev: PointerEvent) { setter(Math.round(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 ? v.lo : v.hi) + step); }
else if (e.key === 'Home') { e.preventDefault(); human(); setter(isLo ? MIN : v.lo); }
else if (e.key === 'End') { e.preventDefault(); human(); setter(isLo ? v.hi : 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);
function onFillDown(e: PointerEvent) {
human(); fill!.setPointerCapture(e.pointerId);
let startX = e.clientX;
const r = track!.getBoundingClientRect();
function move(ev: PointerEvent) {
const deltaPct = (ev.clientX - startX) / r.width * 100;
shiftBoth(deltaPct);
startX = ev.clientX;
}
function up() { fill!.removeEventListener('pointermove', move); fill!.removeEventListener('pointerup', up); }
fill!.addEventListener('pointermove', move); fill!.addEventListener('pointerup', up);
}
fill.addEventListener('pointerdown', onFillDown);
const width = v.hi - v.lo;
let pi = 0;
function tick() {
const c = CENTERS[pi % CENTERS.length]; pi++;
const target = c - width / 2;
shiftBoth(target - v.lo);
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => { offLo(); offHi(); fill!.removeEventListener('pointerdown', onFillDown); window.clearInterval(timer); };
}, []);
return (
<main className={styles.stage}>
<div className={styles.ds + ' ' + styles['is-demo']} ref={rootRef}>
<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={10} aria-valuetext="10" tabIndex={0} />
<button type="button" className={styles.dsThumb + ' ' + styles.dsThumbHi} ref={hiRef} role="slider"
aria-label="최대값" aria-valuemin={0} aria-valuemax={100} aria-valuenow={40} aria-valuetext="40" tabIndex={0} />
</div>
<p className={styles.dsReadout}><span ref={loOutRef}>10</span> – <span ref={hiOutRef}>40</span></p>
<p className={styles.dsCaption}>구간 가운데를 끌면 폭은 그대로 이동</p>
</div>
</main>
);
}04 Range Slider Whose Middle Drag Shifts Both Handles Together — Measured area 4.107% · moving frames 17/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


