Band
import { useEffect, useRef, useState } from 'react';
const W = 240;
const P = 8;
const Y = 26;
const HOURS = 24;
const at = (h: number) => 14 + (h / HOURS) * (W - 28);
const clock = (h: number) => `${Math.floor(h)}:${h % 1 ? '30' : '00'}`;
const css = `
.slider-band { position: relative; width: ${W}px; height: 92px; -webkit-user-select: none; user-select: none; }
.slider-band__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 62px; overflow: visible; }
.slider-band__rubber { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-linejoin: round; }
.slider-band__peg { fill: light-dark(#373a40, #f1f2f3); }
.slider-band__head { fill: light-dark(#fbfcfd, #121417); }
.slider-band__tick { stroke: light-dark(#cbced3, #393c41); stroke-width: 1.5; stroke-linecap: round; }
.slider-band__hour { fill: light-dark(#6d7179, #7d8087); font-size: 10px; text-anchor: middle; }
.slider-band__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-band:has(.slider-band__input--lo:focus-visible) .slider-band__ring--lo,
.slider-band:has(.slider-band__input--hi:focus-visible) .slider-band__ring--hi { stroke: light-dark(#50535b, #c6c8cd); }
.slider-band__value { position: absolute; left: 0; right: 0; top: 70px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; }
.slider-band__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; }
.slider-band__input { position: absolute; left: 0; top: ${Y - 18}px; width: 100%; height: 36px; margin: 0; opacity: 0; pointer-events: none; -webkit-appearance: none; appearance: none; background: none; }
.slider-band__input::-webkit-slider-thumb { -webkit-appearance: none; pointer-events: auto; width: 28px; height: 36px; cursor: grab; }
.slider-band__input::-moz-range-thumb { pointer-events: auto; width: 28px; height: 36px; border: 0; cursor: grab; }
`;
export function BandSlider() {
const [lo, setLo] = useState(9);
const [hi, setHi] = useState(17);
const [d, setD] = useState(0);
const tw = useRef({ amp: 0, t: 0, raf: 0, on: false, len: at(17) - at(9) });
const xl = at(lo);
const xh = at(hi);
const len = xh - xl;
const mid = (xl + xh) / 2;
const r = P + 2;
// Stretched rubber thins out.
const sw = Math.max(1.4, Math.min(6, 300 / len));
// A pluck: the strands vibrate, higher pitched the tighter the band, and die away.
const pluck = (a: number) => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const q = tw.current;
q.amp = Math.min(4.5, Math.max(q.amp, a));
if (q.on) return;
q.on = true;
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.t += dt;
q.amp *= Math.exp(-4.5 * dt);
if (q.amp < 0.05) {
q.on = false;
q.amp = 0;
setD(0);
return;
}
setD(q.amp * Math.sin(q.t * Math.PI * 2 * (5 + q.len / 18)));
q.raf = requestAnimationFrame(step);
};
q.raf = requestAnimationFrame(step);
};
useEffect(() => () => cancelAnimationFrame(tw.current.raf), []);
tw.current.len = len;
const path =
`M ${xl} ${Y - r} Q ${mid} ${Y - r - 2 * d} ${xh} ${Y - r} A ${r} ${r} 0 0 1 ${xh} ${Y + r} ` + `Q ${mid} ${Y + r + 2 * d} ${xl} ${Y + r} A ${r} ${r} 0 0 1 ${xl} ${Y - r} Z`;
return (
<>
<style>
{css}
</style>
<div className="slider-band" onPointerUp={() => pluck(4)} onKeyUp={() => pluck(2.5)}>
<svg className="slider-band__svg" aria-hidden="true">
{[0, 6, 12, 18, 24].map((h) => (
<g key={h}>
<line className="slider-band__tick" x1={at(h)} y1={Y + 18} x2={at(h)} y2={Y + 22} />
<text className="slider-band__hour" x={at(h)} y={Y + 33}>
{h}
</text>
</g>
))}
<path className="slider-band__rubber" d={path} strokeWidth={sw} />
{[xl, xh].map((x, i) => (
<g key={i}>
<circle className="slider-band__peg" cx={x} cy={Y} r={P} />
<circle className="slider-band__head" cx={x} cy={Y} r={2.6} />
<circle className={`slider-band__ring slider-band__ring--${i ? 'hi' : 'lo'}`} cx={x} cy={Y} r={P + 5} />
</g>
))}
</svg>
<p className="slider-band__value" aria-hidden="true">
<b>
{clock(lo)} – {clock(hi)}
</b>
<span>{hi - lo} h</span>
</p>
<input
className="slider-band__input slider-band__input--lo"
type="range"
min={0}
max={HOURS}
step={0.5}
value={lo}
style={{ zIndex: lo > HOURS / 2 ? 2 : 1 }}
onChange={(e) => {
setLo(Math.min(+e.target.value, hi - 1));
pluck(0.8);
}}
aria-label="Start"
aria-valuetext={clock(lo)}
/>
<input
className="slider-band__input slider-band__input--hi"
type="range"
min={0}
max={HOURS}
step={0.5}
value={hi}
style={{ zIndex: lo > HOURS / 2 ? 1 : 2 }}
onChange={(e) => {
setHi(Math.max(+e.target.value, lo + 1));
pluck(0.8);
}}
aria-label="End"
aria-valuetext={clock(hi)}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderBand(el) on the mounted element instead.
<div class="slider-band">
<svg class="slider-band__svg" aria-hidden="true">
<g>
<line class="slider-band__tick" x1="14" y1="44" x2="14" y2="48"></line>
<text class="slider-band__hour" x="14" y="59">0</text>
</g>
<g>
<line class="slider-band__tick" x1="67" y1="44" x2="67" y2="48"></line>
<text class="slider-band__hour" x="67" y="59">6</text>
</g>
<g>
<line class="slider-band__tick" x1="120" y1="44" x2="120" y2="48"></line>
<text class="slider-band__hour" x="120" y="59">12</text>
</g>
<g>
<line class="slider-band__tick" x1="173" y1="44" x2="173" y2="48"></line>
<text class="slider-band__hour" x="173" y="59">18</text>
</g>
<g>
<line class="slider-band__tick" x1="226" y1="44" x2="226" y2="48"></line>
<text class="slider-band__hour" x="226" y="59">24</text>
</g>
<path class="slider-band__rubber" d="M 93.5 16 Q 128.833 16 164.167 16 A 10 10 0 0 1 164.167 36 Q 128.833 36 93.5 36 A 10 10 0 0 1 93.5 16 Z" stroke-width="4.245"></path>
<g>
<circle class="slider-band__peg" cx="93.5" cy="26" r="8"></circle>
<circle class="slider-band__head" cx="93.5" cy="26" r="2.6"></circle>
<circle class="slider-band__ring slider-band__ring--lo" cx="93.5" cy="26" r="13"></circle>
</g>
<g>
<circle class="slider-band__peg" cx="164.167" cy="26" r="8"></circle>
<circle class="slider-band__head" cx="164.167" cy="26" r="2.6"></circle>
<circle class="slider-band__ring slider-band__ring--hi" cx="164.167" cy="26" r="13"></circle>
</g>
</svg>
<p class="slider-band__value" aria-hidden="true">
<b>9:00 – 17:00</b>
<span>8 h</span>
</p>
<input class="slider-band__input slider-band__input--lo" type="range" min="0" max="24" step="0.5" style="z-index:1" aria-label="Start" aria-valuetext="9:00" value="9"/>
<input class="slider-band__input slider-band__input--hi" type="range" min="0" max="24" step="0.5" style="z-index:2" aria-label="End" aria-valuetext="17:00" value="17"/>
</div>.slider-band { position: relative; width: 240px; height: 92px; -webkit-user-select: none; user-select: none; }
.slider-band__svg { position: absolute; left: 0; top: 0; width: 240px; height: 62px; overflow: visible; }
.slider-band__rubber { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-linejoin: round; }
.slider-band__peg { fill: light-dark(#373a40, #f1f2f3); }
.slider-band__head { fill: light-dark(#fbfcfd, #121417); }
.slider-band__tick { stroke: light-dark(#cbced3, #393c41); stroke-width: 1.5; stroke-linecap: round; }
.slider-band__hour { fill: light-dark(#6d7179, #7d8087); font-size: 10px; text-anchor: middle; }
.slider-band__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-band:has(.slider-band__input--lo:focus-visible) .slider-band__ring--lo,
.slider-band:has(.slider-band__input--hi:focus-visible) .slider-band__ring--hi { stroke: light-dark(#50535b, #c6c8cd); }
.slider-band__value { position: absolute; left: 0; right: 0; top: 70px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; }
.slider-band__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; }
.slider-band__input { position: absolute; left: 0; top: 8px; width: 100%; height: 36px; margin: 0; opacity: 0; pointer-events: none; -webkit-appearance: none; appearance: none; background: none; }
.slider-band__input::-webkit-slider-thumb { -webkit-appearance: none; pointer-events: auto; width: 28px; height: 36px; cursor: grab; }
.slider-band__input::-moz-range-thumb { pointer-events: auto; width: 28px; height: 36px; border: 0; cursor: grab; }function initSliderBand(root) {
var W = 240;
var Y = 26;
var R = 10;
var HOURS = 24;
var lo = root.querySelector('.slider-band__input--lo');
var hi = root.querySelector('.slider-band__input--hi');
var rubber = root.querySelector('.slider-band__rubber');
var pegs = root.querySelectorAll('.slider-band__peg');
var heads = root.querySelectorAll('.slider-band__head');
var rings = root.querySelectorAll('.slider-band__ring');
var range = root.querySelector('.slider-band__value b');
var hours = root.querySelector('.slider-band__value span');
var d = 0;
var amp = 0;
var t = 0;
var on = false;
var raf = 0;
function at(h) { return 14 + (h / HOURS) * (W - 28); }
function clock(h) { return Math.floor(h) + ':' + (h % 1 ? '30' : '00'); }
// The band loops round both pegs; stretched rubber thins out, and a pluck bows the two strands apart.
function draw() {
var xl = at(+lo.value);
var xh = at(+hi.value);
var mid = (xl + xh) / 2;
rubber.setAttribute('d',
'M ' + xl + ' ' + (Y - R) + ' Q ' + mid + ' ' + (Y - R - 2 * d) + ' ' + xh + ' ' + (Y - R) + ' A ' + R + ' ' + R + ' 0 0 1 ' + xh + ' ' + (Y + R) +
' Q ' + mid + ' ' + (Y + R + 2 * d) + ' ' + xl + ' ' + (Y + R) + ' A ' + R + ' ' + R + ' 0 0 1 ' + xl + ' ' + (Y - R) + ' Z');
rubber.setAttribute('stroke-width', Math.max(1.4, Math.min(6, 300 / (xh - xl))));
[pegs[0], heads[0], rings[0]].forEach(function (c) { c.setAttribute('cx', xl); });
[pegs[1], heads[1], rings[1]].forEach(function (c) { c.setAttribute('cx', xh); });
}
// The strands vibrate, higher pitched the tighter the band, and die away.
function pluck(a) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
amp = Math.min(4.5, Math.max(amp, a));
if (on) return;
on = true;
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
t += dt;
amp *= Math.exp(-4.5 * dt);
if (amp < 0.05) {
on = false;
amp = 0;
d = 0;
return draw();
}
d = amp * Math.sin(t * Math.PI * 2 * (5 + (at(+hi.value) - at(+lo.value)) / 18));
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function update(e) {
if (e.target === lo && +lo.value > +hi.value - 1) lo.value = +hi.value - 1;
if (e.target === hi && +hi.value < +lo.value + 1) hi.value = +lo.value + 1;
range.textContent = clock(+lo.value) + ' – ' + clock(+hi.value);
hours.textContent = +hi.value - +lo.value + ' h';
lo.setAttribute('aria-valuetext', clock(+lo.value));
hi.setAttribute('aria-valuetext', clock(+hi.value));
lo.style.zIndex = +lo.value > HOURS / 2 ? 2 : 1;
hi.style.zIndex = +lo.value > HOURS / 2 ? 1 : 2;
draw();
pluck(0.8);
}
function release() { pluck(4); }
function keyup() { pluck(2.5); }
lo.addEventListener('input', update);
hi.addEventListener('input', update);
root.addEventListener('pointerup', release);
root.addEventListener('keyup', keyup);
return function () {
cancelAnimationFrame(raf);
lo.removeEventListener('input', update);
hi.removeEventListener('input', update);
root.removeEventListener('pointerup', release);
root.removeEventListener('keyup', keyup);
};
}
document.querySelectorAll('.slider-band').forEach(initSliderBand);Goal: Create a "Band" slider.
Visual behavior: A time range whose fill is a rubber band looped round two pegs: it thins as you pull the pegs apart and thickens as they close, buzzes as you drag and twangs when you let go.
Technique:
- Two real <input type="range"> elements share the track, transparent, with pointer-events off on the inputs and back on for their thumbs, so either handle can be dragged and each works from the keyboard. The low one stacks on top past the midpoint so the pair can always be pulled apart. The drawing is aria-hidden.
- The band is one SVG path: two strands joined by arcs round the pegs. Its stroke width is inversely proportional to its length, clamped, so a long band is thin.
- A pluck sets an amplitude that decays exponentially in requestAnimationFrame; the strands bow apart by amplitude times a sine whose frequency rises with the band's length, like a tighter string. Each change adds a small pluck and releasing the pointer a big one.
Accessibility: Two native range inputs labelled Start and End with clock-time aria-valuetext; keyboard focus rings the peg. Under prefers-reduced-motion the band never vibrates.
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 "Band" slider.
Visual behavior: A time range whose fill is a rubber band looped round two pegs: it thins as you pull the pegs apart and thickens as they close, buzzes as you drag and twangs when you let go.
Technique:
- Two real <input type="range"> elements share the track, transparent, with pointer-events off on the inputs and back on for their thumbs, so either handle can be dragged and each works from the keyboard. The low one stacks on top past the midpoint so the pair can always be pulled apart. The drawing is aria-hidden.
- The band is one SVG path: two strands joined by arcs round the pegs. Its stroke width is inversely proportional to its length, clamped, so a long band is thin.
- A pluck sets an amplitude that decays exponentially in requestAnimationFrame; the strands bow apart by amplitude times a sine whose frequency rises with the band's length, like a tighter string. Each change adds a small pluck and releasing the pointer a big one.
Accessibility: Two native range inputs labelled Start and End with clock-time aria-valuetext; keyboard focus rings the peg. Under prefers-reduced-motion the band never vibrates.
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 time range whose fill is a rubber band looped round two pegs: it thins as you pull the pegs apart and thickens as they close, buzzes as you drag and twangs when you let go.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring
- Techniques
- svg, raf
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Two native range inputs labelled Start and End with clock-time aria-valuetext; keyboard focus rings the peg. Under prefers-reduced-motion the band never vibrates.