02Range Slider That Fills Only Between Its Two Handles
Instead of the whole track, only the stretch between the low and high handle is filled, and each end of that fill follows its handle as it moves — a different demo from 224: 224 (range-slider-dual-thumb-fill) only updates the labels and fills the track, while this one adds a getBoundingClientRect-measured pixel readout of the fill width plus an autoplay sweep phase.
Best forA stats filter where the chosen span needs to stand out at a glance
getBoundingClientRectpxOutaria-valuetext

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__track" id="track">
<div class="ds__fill" id="fill"><div class="ds__sweep" id="sweep"></div></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="78" aria-valuetext="78" tabindex="0"></button>
</div>
<p class="ds__readout"><span id="loOut">20</span> – <span id="hiOut">78</span></p>
<p class="ds__caption">Fill width <span id="pxOut">0</span>px</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-mint;
// 계산 겹: paint() 가 값 두 개를 채움 %로 바꾸고, 실측 트랙 폭(getBoundingClientRect)으로 px 도 환산한다.
// 224 의 듀얼 섬과 다른 시연 — 여긴 채움 안에 sweep 밴드가 값 비율(phase)로 다시 위치를 계산해 돈다.
@include stage($stage-yellow);
.ds { width: min(280px, 84vw); color: $subject-ink; text-align: center; }
.ds__track {
position: relative; height: 8px; border-radius: $r-pill;
background: rgba($subject-ink, .14); margin: $sp-10 0 $sp-4;
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 20%; width: 58%;
background: $color; border-radius: $r-pill; overflow: hidden;
}
.ds__sweep {
position: absolute; top: 0; bottom: 0; left: 40%; width: 22%;
background: linear-gradient(100deg, rgba($subject-cream, 0) 0%, rgba($subject-cream, .85) 50%, rgba($subject-cream, 0) 100%);
transform: translateX(-50%);
}
.ds__thumb {
position: absolute; top: 50%; left: 20%; width: 18px; height: 18px;
appearance: none; border: 0; border-radius: 50%; background: $subject-ink;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%); z-index: 1;
}
.ds__thumb--hi { left: 78%; }
.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, .72); }
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill,
.ds.is-demo .ds__sweep {
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, .ds.is-demo .ds__sweep { transition: none; }
}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);
}
.ds {
width: min(280px, 84vw);
color: #17141a;
text-align: center;
}
.ds__track {
position: relative;
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
margin: 40px 0 16px;
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 20%;
width: 58%;
background: #4cd4a6;
border-radius: 999px;
overflow: hidden;
}
.ds__sweep {
position: absolute;
top: 0;
bottom: 0;
left: 40%;
width: 22%;
background: linear-gradient(100deg, rgba(255, 247, 230, 0) 0%, rgba(255, 247, 230, 0.85) 50%, rgba(255, 247, 230, 0) 100%);
transform: translateX(-50%);
}
.ds__thumb {
position: absolute;
top: 50%;
left: 20%;
width: 18px;
height: 18px;
appearance: none;
border: 0;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
transform: translate(-50%, -50%);
z-index: 1;
}
.ds__thumb--hi {
left: 78%;
}
.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.72);
}
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill,
.ds.is-demo .ds__sweep {
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, .ds.is-demo .ds__sweep {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100;
var root = document.getElementById('ds');
var track = document.getElementById('track');
var fill = document.getElementById('fill');
var sweep = document.getElementById('sweep');
var lo = document.getElementById('thumbLo');
var hi = document.getElementById('thumbHi');
var loOut = document.getElementById('loOut');
var hiOut = document.getElementById('hiOut');
var pxOut = document.getElementById('pxOut');
var v = { lo: 20, hi: 78, phase: 0.2 };
// 계산 겹: 두 값을 트랙 채움 %로 바꾸고, 실측 트랙 폭(getBoundingClientRect)으로 채움을 px 로도 환산한다.
function paint() {
lo.style.left = v.lo + '%'; hi.style.left = v.hi + '%';
fill.style.left = v.lo + '%'; fill.style.width = (v.hi - v.lo) + '%';
var sweepPct = v.lo + (v.hi - v.lo) * v.phase;
var trackPct = v.hi - v.lo === 0 ? 0 : (sweepPct - v.lo) / (v.hi - v.lo) * 100;
sweep.style.left = trackPct + '%';
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);
var r = track.getBoundingClientRect();
pxOut.textContent = String(Math.round(r.width * (v.hi - v.lo) / 100));
}
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(); }
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 ? 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);
window.addEventListener('resize', paint);
var presets = [[8, 92, 0.15], [30, 55, 0.9], [45, 88, 0.35], [12, 40, 0.7], [60, 96, 0.5]];
var pi = 0;
function tick() {
var p = presets[pi % presets.length]; pi++;
v.lo = p[0]; v.hi = p[1]; v.phase = p[2];
paint();
}
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 './DualSliderFillRangeTrack.module.scss';
const MIN = 0;
const MAX = 100;
const TICK_MS = 480;
const PRESETS: [number, number, number][] = [[8, 92, 0.15], [30, 55, 0.9], [45, 88, 0.35], [12, 40, 0.7], [60, 96, 0.5]];
export default function DualSliderFillRangeTrack() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLDivElement>(null);
const sweepRef = useRef<HTMLDivElement>(null);
const loRef = useRef<HTMLButtonElement>(null);
const hiRef = useRef<HTMLButtonElement>(null);
const loOutRef = useRef<HTMLSpanElement>(null);
const hiOutRef = useRef<HTMLSpanElement>(null);
const pxOutRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const sweep = sweepRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const loOut = loOutRef.current;
const hiOut = hiOutRef.current;
const pxOut = pxOutRef.current;
if (!root || !track || !fill || !sweep || !lo || !hi || !loOut || !hiOut || !pxOut) return undefined;
const v = { lo: 20, hi: 78, phase: 0.2 };
// 계산 겹: 두 값을 트랙 채움 %로 바꾸고, 실측 트랙 폭(getBoundingClientRect)으로 채움을 px 로도 환산한다.
function paint() {
lo!.style.left = v.lo + '%'; hi!.style.left = v.hi + '%';
fill!.style.left = v.lo + '%'; fill!.style.width = (v.hi - v.lo) + '%';
const sweepPct = v.lo + (v.hi - v.lo) * v.phase;
const trackPct = v.hi - v.lo === 0 ? 0 : (sweepPct - v.lo) / (v.hi - v.lo) * 100;
sweep!.style.left = trackPct + '%';
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);
const r = track!.getBoundingClientRect();
pxOut!.textContent = String(Math.round(r.width * (v.hi - v.lo) / 100));
}
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(); }
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() { root!.classList.remove(styles['is-demo']); 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 ? 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);
window.addEventListener('resize', paint);
let pi = 0;
function tick() {
const p = PRESETS[pi % PRESETS.length]; pi++;
v.lo = p[0]; v.hi = p[1]; v.phase = p[2];
paint();
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => { offLo(); offHi(); window.removeEventListener('resize', paint); 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}>
<div className={styles.dsSweep} ref={sweepRef} />
</div>
<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={78} aria-valuetext="78" tabIndex={0} />
</div>
<p className={styles.dsReadout}><span ref={loOutRef}>20</span> – <span ref={hiOutRef}>78</span></p>
<p className={styles.dsCaption}>채움 폭 <span ref={pxOutRef}>0</span>px</p>
</div>
</main>
);
}02 Range Slider That Fills Only Between Its Two Handles — Measured area 3.797% · 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
Inputs01 iOS wheel — a circular color picker that recomputes hue from a point's angle9 Color Picker Design Patterns (Copy-Paste)
Inputs01 Step Connector That Fills With a Spring OvershootStep Indicator UI: 9 Physics-Based Patterns
Inputs02 Spring Ring That Fills with the Characters UsedTextarea Character Counter: 9 Grapheme-Aware UIs