Merge
import { useState } from 'react';
const W = 240;
const MAX = 1000;
const MIN_GAP = 30;
const MID = 54;
const THUMB = 22;
const css = `
.slider-merge { position: relative; width: ${W}px; height: 72px; }
.slider-merge__rail { position: absolute; left: 0; right: 0; top: ${MID - 2}px; height: 4px; border-radius: 2px; background: light-dark(#ebedf0, #232529); }
.slider-merge__band { position: absolute; top: ${MID - 2}px; height: 4px; border-radius: 2px; background: light-dark(#42454c, #e5e6e9); }
.slider-merge__thumb {
position: absolute;
top: ${MID - THUMB / 2}px;
width: ${THUMB}px;
height: ${THUMB}px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5));
transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-merge__pill {
position: absolute;
top: 8px;
height: 24px;
padding: 0 9px;
border-radius: 12px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#42454c, #e5e6e9);
font-size: 12px;
font-weight: 500;
line-height: 24px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
transition: transform 0.4s cubic-bezier(0.3, 1.4, 0.5, 1), opacity 0.22s ease-out, background-color 0.2s, color 0.2s;
}
.slider-merge:has(.slider-merge__input--lo:active) .slider-merge__thumb--lo,
.slider-merge:has(.slider-merge__input--hi:active) .slider-merge__thumb--hi { transform: scale(1.15); }
.slider-merge:has(.slider-merge__input--lo:active) .slider-merge__pill--lo,
.slider-merge:has(.slider-merge__input--hi:active) .slider-merge__pill--hi,
.slider-merge:has(.slider-merge__input:active) .slider-merge__pill--both { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.slider-merge:has(.slider-merge__input--lo:focus-visible) .slider-merge__thumb--lo,
.slider-merge:has(.slider-merge__input--hi:focus-visible) .slider-merge__thumb--hi { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-merge__input { position: absolute; left: 0; top: ${MID - 22}px; width: 100%; height: 44px; margin: 0; opacity: 0; pointer-events: none; -webkit-appearance: none; appearance: none; background: none; }
.slider-merge__input::-webkit-slider-thumb { -webkit-appearance: none; pointer-events: auto; width: ${THUMB}px; height: 44px; cursor: grab; }
.slider-merge__input::-moz-range-thumb { pointer-events: auto; width: ${THUMB}px; height: 44px; border: 0; cursor: grab; }
@media (prefers-reduced-motion: reduce) {
.slider-merge__thumb, .slider-merge__pill { transition: opacity 0.22s; }
}
`;
const money = (n: number) => `$${n.toLocaleString('en-US')}`;
// Close enough to the pill's rendered width to decide when two would touch.
const pillWidth = (text: string) => text.length * 7 + 18;
export function MergeSlider() {
const [lo, setLo] = useState(240);
const [hi, setHi] = useState(620);
const at = (n: number) => THUMB / 2 + (n / MAX) * (W - THUMB);
const [xl, xh] = [at(lo), at(hi)];
const mid = (xl + xh) / 2;
const merged = xh - xl < (pillWidth(money(lo)) + pillWidth(money(hi))) / 2 + 6;
// Merging, each pill slides to the midpoint and fades as the joint one grows there.
const pill = (x: number, shown: boolean): React.CSSProperties => ({
left: x,
opacity: shown ? 1 : 0,
transform: `translateX(-50%) translateX(${shown ? 0 : mid - x}px) scale(${shown ? 1 : 0.7})`,
});
return (
<>
<style>
{css}
</style>
<div className="slider-merge">
<span className="slider-merge__rail" aria-hidden="true" />
<span className="slider-merge__band" style={{ left: xl, width: xh - xl }} aria-hidden="true" />
<span className="slider-merge__pill slider-merge__pill--lo" style={pill(xl, !merged)} aria-hidden="true">
{money(lo)}
</span>
<span className="slider-merge__pill slider-merge__pill--hi" style={pill(xh, !merged)} aria-hidden="true">
{money(hi)}
</span>
{/* The pair merges into one label once the two would touch. */}
<span
className="slider-merge__pill slider-merge__pill--both"
style={{ left: mid, opacity: merged ? 1 : 0, transform: `translateX(-50%) scale(${merged ? 1 : 0.7}, ${merged ? 1 : 0.9})` }}
aria-hidden="true"
>
{money(lo)} – {money(hi)}
</span>
<span className="slider-merge__thumb slider-merge__thumb--lo" style={{ left: xl - THUMB / 2 }} aria-hidden="true" />
<span className="slider-merge__thumb slider-merge__thumb--hi" style={{ left: xh - THUMB / 2 }} aria-hidden="true" />
<input
className="slider-merge__input slider-merge__input--lo"
type="range"
min={0}
max={MAX}
step={10}
value={lo}
// Near the top end the low thumb sits on top, so the pair can always be pulled apart.
style={{ zIndex: lo > MAX / 2 ? 2 : 1 }}
onChange={(e) => setLo(Math.min(+e.target.value, hi - MIN_GAP))}
aria-label="Minimum price"
aria-valuetext={money(lo)}
/>
<input
className="slider-merge__input slider-merge__input--hi"
type="range"
min={0}
max={MAX}
step={10}
value={hi}
style={{ zIndex: lo > MAX / 2 ? 1 : 2 }}
onChange={(e) => setHi(Math.max(+e.target.value, lo + MIN_GAP))}
aria-label="Maximum price"
aria-valuetext={money(hi)}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderMerge(el) on the mounted element instead.
<div class="slider-merge">
<span class="slider-merge__rail" aria-hidden="true"></span>
<span class="slider-merge__band" style="left:63.32px;width:82.84px" aria-hidden="true"></span>
<span class="slider-merge__pill slider-merge__pill--lo" style="left:63.32px;opacity:1;transform:translateX(-50%) translateX(0px) scale(1)" aria-hidden="true">$240</span>
<span class="slider-merge__pill slider-merge__pill--hi" style="left:146.16px;opacity:1;transform:translateX(-50%) translateX(0px) scale(1)" aria-hidden="true">$620</span>
<span class="slider-merge__pill slider-merge__pill--both" style="left:104.74px;opacity:0;transform:translateX(-50%) scale(0.7, 0.9)" aria-hidden="true">$240 – $620</span>
<span class="slider-merge__thumb slider-merge__thumb--lo" style="left:52.32px" aria-hidden="true"></span>
<span class="slider-merge__thumb slider-merge__thumb--hi" style="left:135.16px" aria-hidden="true"></span>
<input class="slider-merge__input slider-merge__input--lo" type="range" min="0" max="1000" step="10" style="z-index:1" aria-label="Minimum price" aria-valuetext="$240" value="240"/>
<input class="slider-merge__input slider-merge__input--hi" type="range" min="0" max="1000" step="10" style="z-index:2" aria-label="Maximum price" aria-valuetext="$620" value="620"/>
</div>.slider-merge { position: relative; width: 240px; height: 72px; }
.slider-merge__rail { position: absolute; left: 0; right: 0; top: 52px; height: 4px; border-radius: 2px; background: light-dark(#ebedf0, #232529); }
.slider-merge__band { position: absolute; top: 52px; height: 4px; border-radius: 2px; background: light-dark(#42454c, #e5e6e9); }
.slider-merge__thumb {
position: absolute;
top: 43px;
width: 22px;
height: 22px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5));
transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-merge__pill {
position: absolute;
top: 8px;
height: 24px;
padding: 0 9px;
border-radius: 12px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#42454c, #e5e6e9);
font-size: 12px;
font-weight: 500;
line-height: 24px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
transition: transform 0.4s cubic-bezier(0.3, 1.4, 0.5, 1), opacity 0.22s ease-out, background-color 0.2s, color 0.2s;
}
.slider-merge:has(.slider-merge__input--lo:active) .slider-merge__thumb--lo,
.slider-merge:has(.slider-merge__input--hi:active) .slider-merge__thumb--hi { transform: scale(1.15); }
.slider-merge:has(.slider-merge__input--lo:active) .slider-merge__pill--lo,
.slider-merge:has(.slider-merge__input--hi:active) .slider-merge__pill--hi,
.slider-merge:has(.slider-merge__input:active) .slider-merge__pill--both { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.slider-merge:has(.slider-merge__input--lo:focus-visible) .slider-merge__thumb--lo,
.slider-merge:has(.slider-merge__input--hi:focus-visible) .slider-merge__thumb--hi { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-merge__input { position: absolute; left: 0; top: 32px; width: 100%; height: 44px; margin: 0; opacity: 0; pointer-events: none; -webkit-appearance: none; appearance: none; background: none; }
.slider-merge__input::-webkit-slider-thumb { -webkit-appearance: none; pointer-events: auto; width: 22px; height: 44px; cursor: grab; }
.slider-merge__input::-moz-range-thumb { pointer-events: auto; width: 22px; height: 44px; border: 0; cursor: grab; }
@media (prefers-reduced-motion: reduce) {
.slider-merge__thumb, .slider-merge__pill { transition: opacity 0.22s; }
}function initSliderMerge(root) {
var W = 240, THUMB = 22, MIN_GAP = 30;
var lo = root.querySelector('.slider-merge__input--lo');
var hi = root.querySelector('.slider-merge__input--hi');
var band = root.querySelector('.slider-merge__band');
var pillLo = root.querySelector('.slider-merge__pill--lo');
var pillHi = root.querySelector('.slider-merge__pill--hi');
var pillBoth = root.querySelector('.slider-merge__pill--both');
var thumbLo = root.querySelector('.slider-merge__thumb--lo');
var thumbHi = root.querySelector('.slider-merge__thumb--hi');
var max = +lo.max;
function money(n) { return '$' + n.toLocaleString('en-US'); }
// Close enough to the pill's rendered width to decide when two would touch.
function pillWidth(text) { return text.length * 7 + 18; }
function at(n) { return THUMB / 2 + (n / max) * (W - THUMB); }
function update(e) {
// Each thumb stops short of the other.
if (e && e.target === lo) lo.value = Math.min(+lo.value, +hi.value - MIN_GAP);
if (e && e.target === hi) hi.value = Math.max(+hi.value, +lo.value + MIN_GAP);
var a = +lo.value, b = +hi.value;
var xl = at(a), xh = at(b), mid = (xl + xh) / 2;
var merged = xh - xl < (pillWidth(money(a)) + pillWidth(money(b))) / 2 + 6;
band.style.left = xl + 'px';
band.style.width = xh - xl + 'px';
thumbLo.style.left = xl - THUMB / 2 + 'px';
thumbHi.style.left = xh - THUMB / 2 + 'px';
// Merging, each pill slides to the midpoint and fades as the joint one grows there.
[[pillLo, xl, a], [pillHi, xh, b]].forEach(function (p) {
p[0].textContent = money(p[2]);
p[0].style.left = p[1] + 'px';
p[0].style.opacity = merged ? 0 : 1;
p[0].style.transform = 'translateX(-50%) translateX(' + (merged ? mid - p[1] : 0) + 'px) scale(' + (merged ? 0.7 : 1) + ')';
});
pillBoth.textContent = money(a) + ' – ' + money(b);
pillBoth.style.left = mid + 'px';
pillBoth.style.opacity = merged ? 1 : 0;
pillBoth.style.transform = 'translateX(-50%) scale(' + (merged ? '1, 1' : '0.7, 0.9') + ')';
lo.setAttribute('aria-valuetext', money(a));
hi.setAttribute('aria-valuetext', money(b));
// Near the top end the low thumb sits on top, so the pair can always be pulled apart.
lo.style.zIndex = a > max / 2 ? 2 : 1;
hi.style.zIndex = a > max / 2 ? 1 : 2;
}
lo.addEventListener('input', update);
hi.addEventListener('input', update);
return function () {
lo.removeEventListener('input', update);
hi.removeEventListener('input', update);
};
}
document.querySelectorAll('.slider-merge').forEach(initSliderMerge);Goal: Create a "Merge" slider.
Visual behavior: A price range with two thumbs, each with its value above it; bring them close and the two labels slide together and merge into one range label.
Technique:
- Two native range inputs share one track, transparent and with pointer events only on their thumbs, so either thumb can be dragged and keyboard users get two real sliders. Each value is clamped to stay a fixed gap from the other.
- From an estimate of each label's width, the labels merge once they would touch: each slides to the midpoint and fades while the joint label scales up there.
- Near the top end the low input is stacked above the high one, so two thumbs pushed together can always be pulled apart.
Accessibility: Two native range inputs labelled Minimum price and Maximum price, each with an aria-valuetext in dollars; keyboard focus rings the matching thumb. Under prefers-reduced-motion the labels merge without moving.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Merge" slider.
Visual behavior: A price range with two thumbs, each with its value above it; bring them close and the two labels slide together and merge into one range label.
Technique:
- Two native range inputs share one track, transparent and with pointer events only on their thumbs, so either thumb can be dragged and keyboard users get two real sliders. Each value is clamped to stay a fixed gap from the other.
- From an estimate of each label's width, the labels merge once they would touch: each slides to the midpoint and fades while the joint label scales up there.
- Near the top end the low input is stacked above the high one, so two thumbs pushed together can always be pulled apart.
Accessibility: Two native range inputs labelled Minimum price and Maximum price, each with an aria-valuetext in dollars; keyboard focus rings the matching thumb. Under prefers-reduced-motion the labels merge without moving.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A price range with two thumbs, each with its value above it; bring them close and the two labels slide together and merge into one range label.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, continuous, morph
- Techniques
- css transition, transform only
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Two native range inputs labelled Minimum price and Maximum price, each with an aria-valuetext in dollars; keyboard focus rings the matching thumb. Under prefers-reduced-motion the labels merge without moving.