03Range Slider Whose Two Tooltips Merge When Values Get Close
A value bubble follows each handle, and once the two values draw near each other the bubbles combine into one, listing both values side by side instead of overlapping.
Best forA booking-date filter for narrowing in on a tight span precisely
MERGE_PX=46getBoundingClientRectrole=slider

<main class="stage">
<div class="ds is-demo" id="ds">
<div class="ds__track" id="track">
<div class="ds__fill" id="fill"></div>
<div class="ds__bubble ds__bubble--lo" id="bubbleLo">20</div>
<div class="ds__bubble ds__bubble--hi" id="bubbleHi">76</div>
<div class="ds__bubble ds__bubble--merged" id="bubbleMerged">20 – 76</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="76" aria-valuetext="76" tabindex="0"></button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-ink;
// 계산 겹: 두 풍선 사이 실제 화면 간격(px)이 MERGE_PX 보다 좁아지면 root 에 is-merged 를 켜
// 개별 풍선은 숨기고 합친 풍선 하나로 값을 나란히 보여준다.
@include stage($stage-orange);
.ds { width: min(300px, 86vw); color: $subject-ink; }
.ds__track {
position: relative; height: 6px; border-radius: $r-pill;
background: rgba($subject-ink, .18); margin: $sp-12 0 $sp-6;
}
.ds__fill {
position: absolute; top: 0; bottom: 0; left: 20%; width: 56%;
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-ink;
box-shadow: $shadow-press; cursor: grab; touch-action: none; padding: 0;
transform: translate(-50%, -50%); z-index: 2;
}
.ds__thumb--hi { left: 76%; }
.ds__thumb:focus-visible { outline: 2px solid $subject-blue-deep; outline-offset: 2px; }
.ds__thumb:active { cursor: grabbing; }
.ds__bubble {
position: absolute; top: -$sp-10; transform: translate(-50%, 0);
background: $subject-ink; color: $subject-cream; border-radius: $r-sm;
padding: $sp-1 $sp-2; font-weight: 700; font-size: 12px; line-height: 1.3;
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.ds__bubble--merged { opacity: 0; background: $subject-orange-deep; }
.ds.is-merged .ds__bubble--lo,
.ds.is-merged .ds__bubble--hi { opacity: 0; transform: translate(-50%, 0) scale(.6); }
.ds.is-merged .ds__bubble--merged { opacity: 1; }
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill,
.ds.is-demo .ds__bubble {
transition: left $duration $easing, width $duration $easing, opacity $dur-quick $ease-out, transform $dur-quick $ease-out;
}
@media (max-width: 340px) {
.ds { width: min(250px, 82vw); }
.ds__bubble { font-size: 11px; top: -$sp-8; }
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill, .ds.is-demo .ds__bubble { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ds {
width: min(300px, 86vw);
color: #17141a;
}
.ds__track {
position: relative;
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
margin: 48px 0 24px;
}
.ds__fill {
position: absolute;
top: 0;
bottom: 0;
left: 20%;
width: 56%;
background: #17141a;
border-radius: 999px;
}
.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: 2;
}
.ds__thumb--hi {
left: 76%;
}
.ds__thumb:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.ds__thumb:active {
cursor: grabbing;
}
.ds__bubble {
position: absolute;
top: -40px;
transform: translate(-50%, 0);
background: #17141a;
color: #fff7e6;
border-radius: 8px;
padding: 4px 8px;
font-weight: 700;
font-size: 12px;
line-height: 1.3;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ds__bubble--merged {
opacity: 0;
background: #c93000;
}
.ds.is-merged .ds__bubble--lo,
.ds.is-merged .ds__bubble--hi {
opacity: 0;
transform: translate(-50%, 0) scale(0.6);
}
.ds.is-merged .ds__bubble--merged {
opacity: 1;
}
.ds.is-demo .ds__thumb,
.ds.is-demo .ds__fill,
.ds.is-demo .ds__bubble {
transition: left 2s cubic-bezier(0.2, 1.4, 0.4, 1), width 2s cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms ease-out, transform 220ms ease-out;
}
@media (max-width: 340px) {
.ds {
width: min(250px, 82vw);
}
.ds__bubble {
font-size: 11px;
top: -32px;
}
}
@media (prefers-reduced-motion: reduce) {
.ds.is-demo .ds__thumb, .ds.is-demo .ds__fill, .ds.is-demo .ds__bubble {
transition: none;
}
}(function () {
var MIN = 0, MAX = 100, MERGE_PX = 46;
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 bLo = document.getElementById('bubbleLo');
var bHi = document.getElementById('bubbleHi');
var bMerged = document.getElementById('bubbleMerged');
var v = { lo: 20, hi: 76 };
// 계산 겹: 두 값 풍선의 실제 화면 간격(px)을 트랙 실측 폭으로 구해 MERGE_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) + '%';
bLo.style.left = v.lo + '%'; bHi.style.left = v.hi + '%';
bMerged.style.left = (v.lo + v.hi) / 2 + '%';
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));
bLo.textContent = String(v.lo); bHi.textContent = String(v.hi);
bMerged.textContent = v.lo + ' – ' + v.hi;
var r = track.getBoundingClientRect();
var gapPx = r.width * (v.hi - v.lo) / 100;
var merged = gapPx < MERGE_PX;
root.classList.toggle('is-merged', merged);
}
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);
var presets = [[44, 52], [10, 90], [38, 44], [15, 70], [58, 63]];
var pi = 0;
function tick() {
var p = presets[pi % presets.length]; pi++;
v.lo = p[0]; v.hi = p[1]; 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 './DualSliderTooltipPairFollow.module.scss';
const MIN = 0;
const MAX = 100;
const MERGE_PX = 46;
const TICK_MS = 480;
const PRESETS: [number, number][] = [[44, 52], [10, 90], [38, 44], [15, 70], [58, 63]];
export default function DualSliderTooltipPairFollow() {
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 bLoRef = useRef<HTMLDivElement>(null);
const bHiRef = useRef<HTMLDivElement>(null);
const bMergedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const fill = fillRef.current;
const lo = loRef.current;
const hi = hiRef.current;
const bLo = bLoRef.current;
const bHi = bHiRef.current;
const bMerged = bMergedRef.current;
if (!root || !track || !fill || !lo || !hi || !bLo || !bHi || !bMerged) return undefined;
const v = { lo: 20, hi: 76 };
// 계산 겹: 두 값 풍선의 실제 화면 간격(px)을 트랙 실측 폭으로 구해 MERGE_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) + '%';
bLo!.style.left = v.lo + '%'; bHi!.style.left = v.hi + '%';
bMerged!.style.left = (v.lo + v.hi) / 2 + '%';
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));
bLo!.textContent = String(v.lo); bHi!.textContent = String(v.hi);
bMerged!.textContent = v.lo + ' – ' + v.hi;
const r = track!.getBoundingClientRect();
const gapPx = r.width * (v.hi - v.lo) / 100;
const merged = gapPx < MERGE_PX;
root!.classList.toggle(styles['is-merged'], merged);
}
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);
let pi = 0;
function tick() {
const p = PRESETS[pi % PRESETS.length]; pi++;
v.lo = p[0]; v.hi = p[1]; paint();
}
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) timer = window.setInterval(tick, TICK_MS);
paint();
return () => { offLo(); offHi(); 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.dsBubble + ' ' + styles.dsBubbleLo} ref={bLoRef}>20</div>
<div className={styles.dsBubble + ' ' + styles.dsBubbleHi} ref={bHiRef}>76</div>
<div className={styles.dsBubble + ' ' + styles.dsBubbleMerged} ref={bMergedRef}>20 – 76</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={76} aria-valuetext="76" tabIndex={0} />
</div>
</div>
</main>
);
}03 Range Slider Whose Two Tooltips Merge When Values Get Close — Measured area 8.158% · 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
Inputs07 Arrival chip whose date slides over when you change the shipping speedCheckout Page Design — 9 Cart Patterns, No Library
Inputs04 Mask value toggle — when the masking value turns on, all but the first few digit values switch to a symbol valueCredit Card Input UI: 9 Patterns
Inputs09 A Select Field Whose Chevron Turns on a Spring and Whose Label Lifts When an Option Is PickedCSS Floating Label: 9 Inputs on Spring Physics