Tempo
import { useEffect, useRef, useState } from 'react';
const W = 240;
const T = 18;
const Y = 26;
const MIN = 40;
const MAX = 200;
const at = (b: number) => T / 2 + ((b - MIN) / (MAX - MIN)) * (W - T);
type Pulse = { t0: number; x: number; strong: boolean };
const css = `
.slider-tempo { position: relative; width: ${W}px; height: 74px; }
.slider-tempo__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 52px; overflow: visible; }
.slider-tempo__rail { stroke: light-dark(#ebedf0, #232529); stroke-width: 4; stroke-linecap: round; }
.slider-tempo__fill { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 4; stroke-linecap: round; }
.slider-tempo__run { fill: light-dark(#373a40, #f1f2f3); }
.slider-tempo__wave { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.5; }
.slider-tempo__thumb { fill: light-dark(#373a40, #f1f2f3); }
.slider-tempo__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-tempo:has(.slider-tempo__input:focus-visible) .slider-tempo__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-tempo__value { position: absolute; left: 0; right: 0; top: 52px; display: flex; justify-content: space-between; align-items: center; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-tempo__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-tempo__beats { display: inline-flex; gap: 4px; margin-left: 8px; vertical-align: middle; }
.slider-tempo__beat { width: 5px; height: 5px; border-radius: 50%; background: light-dark(#dbdde1, #2e3035); }
.slider-tempo__beat--on { background: light-dark(#373a40, #f1f2f3); }
.slider-tempo__input { position: absolute; left: 0; top: ${Y - 18}px; width: 100%; height: 36px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-tempo__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${T}px; height: 36px; }
.slider-tempo__input::-moz-range-thumb { width: ${T}px; height: 36px; border: 0; }
`;
export function TempoSlider() {
const [bpm, setBpm] = useState(96);
const [beat, setBeat] = useState(-1);
const [, setFrame] = useState(0);
const box = useRef<HTMLDivElement>(null);
const st = useRef({ bpm: 96, last: 0, n: -1, now: 0, raf: 0, pulses: [] as Pulse[] });
// The thumb keeps time: every beat it kicks and sends a pulse both ways down the track,
// with every fourth beat accented. Paused off screen.
useEffect(() => {
const q = st.current;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const step = (now: number) => {
q.now = now;
const gap = 60000 / q.bpm;
if (now - q.last >= gap) {
q.last = now - q.last < gap * 2 ? q.last + gap : now;
q.n += 1;
q.pulses = [...q.pulses.filter((p) => now - p.t0 < 700), { t0: now, x: at(q.bpm), strong: q.n % 4 === 0 }];
setBeat(q.n % 4);
}
setFrame((f) => f + 1);
q.raf = requestAnimationFrame(step);
};
const io = new IntersectionObserver(([e]) => {
cancelAnimationFrame(q.raf);
if (e.isIntersecting) {
q.last = performance.now();
q.raf = requestAnimationFrame(step);
}
});
io.observe(box.current!);
return () => {
io.disconnect();
cancelAnimationFrame(q.raf);
};
}, []);
const q = st.current;
const x = at(bpm);
const latest = q.pulses[q.pulses.length - 1];
const age = latest ? (q.now - latest.t0) / 1000 : 9;
const kick = 1 + (latest?.strong ? 0.38 : 0.22) * Math.exp(-age / 0.09);
return (
<>
<style>
{css}
</style>
<div className="slider-tempo" ref={box}>
<svg className="slider-tempo__svg" aria-hidden="true">
<line className="slider-tempo__rail" x1={T / 2} y1={Y} x2={W - T / 2} y2={Y} />
<line className="slider-tempo__fill" x1={T / 2} y1={Y} x2={x} y2={Y} />
{q.pulses.map((p) => {
const a = (q.now - p.t0) / 1000;
if (a > 0.7) return null;
const run = a * 260;
return (
<g key={p.t0}>
<circle className="slider-tempo__wave" cx={p.x} cy={Y} r={T / 2 + a * 40} opacity={Math.max(0, 0.7 - a * 1.4)} />
{[p.x - run, p.x + run].map((rx, i) =>
rx > 2 && rx < W - 2 ? <circle key={i} className="slider-tempo__run" cx={rx} cy={Y} r={p.strong ? 2.6 : 1.9} opacity={1 - a / 0.7} /> : null,
)}
</g>
);
})}
<circle className="slider-tempo__thumb" cx={x} cy={Y} r={(T / 2) * kick} />
<circle className="slider-tempo__ring" cx={x} cy={Y} r={T / 2 + 4} />
</svg>
<p className="slider-tempo__value" aria-hidden="true">
<span>
Tempo
<span className="slider-tempo__beats">
{[0, 1, 2, 3].map((i) => (
<span key={i} className={`slider-tempo__beat${i === beat ? ' slider-tempo__beat--on' : ''}`} />
))}
</span>
</span>
<b>{bpm} BPM</b>
</p>
<input
className="slider-tempo__input"
type="range"
min={MIN}
max={MAX}
value={bpm}
onChange={(e) => {
setBpm(+e.target.value);
st.current.bpm = +e.target.value;
}}
aria-label="Tempo"
aria-valuetext={`${bpm} beats per minute`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderTempo(el) on the mounted element instead.
<div class="slider-tempo">
<svg class="slider-tempo__svg" aria-hidden="true">
<line class="slider-tempo__rail" x1="9" y1="26" x2="231" y2="26"></line>
<line class="slider-tempo__fill" x1="9" y1="26" x2="86.7" y2="26"></line>
<circle class="slider-tempo__thumb" cx="86.7" cy="26" r="9"></circle>
<circle class="slider-tempo__ring" cx="86.7" cy="26" r="13"></circle>
</svg>
<p class="slider-tempo__value" aria-hidden="true">
<span>
Tempo
<span class="slider-tempo__beats">
<span class="slider-tempo__beat"></span>
<span class="slider-tempo__beat"></span>
<span class="slider-tempo__beat"></span>
<span class="slider-tempo__beat"></span>
</span>
</span>
<b>96 BPM</b>
</p>
<input class="slider-tempo__input" type="range" min="40" max="200" aria-label="Tempo" aria-valuetext="96 beats per minute" value="96"/>
</div>.slider-tempo { position: relative; width: 240px; height: 74px; }
.slider-tempo__svg { position: absolute; left: 0; top: 0; width: 240px; height: 52px; overflow: visible; }
.slider-tempo__rail { stroke: light-dark(#ebedf0, #232529); stroke-width: 4; stroke-linecap: round; }
.slider-tempo__fill { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 4; stroke-linecap: round; }
.slider-tempo__run { fill: light-dark(#373a40, #f1f2f3); }
.slider-tempo__wave { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.5; }
.slider-tempo__thumb { fill: light-dark(#373a40, #f1f2f3); }
.slider-tempo__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-tempo:has(.slider-tempo__input:focus-visible) .slider-tempo__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-tempo__value { position: absolute; left: 0; right: 0; top: 52px; display: flex; justify-content: space-between; align-items: center; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-tempo__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-tempo__beats { display: inline-flex; gap: 4px; margin-left: 8px; vertical-align: middle; }
.slider-tempo__beat { width: 5px; height: 5px; border-radius: 50%; background: light-dark(#dbdde1, #2e3035); }
.slider-tempo__beat--on { background: light-dark(#373a40, #f1f2f3); }
.slider-tempo__input { position: absolute; left: 0; top: 8px; width: 100%; height: 36px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-tempo__input::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 36px; }
.slider-tempo__input::-moz-range-thumb { width: 18px; height: 36px; border: 0; }function initSliderTempo(root) {
var W = 240;
var T = 18;
var Y = 26;
var MIN = 40;
var MAX = 200;
var NS = 'http://www.w3.org/2000/svg';
var input = root.querySelector('.slider-tempo__input');
var svg = root.querySelector('.slider-tempo__svg');
var fill = root.querySelector('.slider-tempo__fill');
var thumb = root.querySelector('.slider-tempo__thumb');
var ring = root.querySelector('.slider-tempo__ring');
var beats = root.querySelectorAll('.slider-tempo__beat');
var label = root.querySelector('.slider-tempo__value b');
var pulses = [];
var last = 0;
var n = -1;
var raf = 0;
function at(b) { return T / 2 + ((b - MIN) / (MAX - MIN)) * (W - T); }
function circle(cls) {
var c = document.createElementNS(NS, 'circle');
c.setAttribute('class', cls);
c.setAttribute('cy', Y);
return c;
}
// Every beat sends a ring out from the thumb and a pulse running both ways down the track.
function beat(now) {
var g = document.createElementNS(NS, 'g');
var p = { t0: now, x: at(+input.value), strong: n % 4 === 0, g: g, wave: circle('slider-tempo__wave'), runs: [circle('slider-tempo__run'), circle('slider-tempo__run')] };
p.wave.setAttribute('cx', p.x);
g.appendChild(p.wave);
p.runs.forEach(function (r) {
r.setAttribute('r', p.strong ? 2.6 : 1.9);
g.appendChild(r);
});
svg.insertBefore(g, thumb);
pulses.push(p);
beats.forEach(function (b, i) { b.classList.toggle('slider-tempo__beat--on', i === n % 4); });
}
// The thumb keeps time, with every fourth beat accented.
function step(now) {
var gap = 60000 / +input.value;
if (now - last >= gap) {
last = now - last < gap * 2 ? last + gap : now;
n += 1;
beat(now);
}
pulses = pulses.filter(function (p) {
var a = (now - p.t0) / 1000;
if (a > 0.7) {
p.g.remove();
return false;
}
var run = a * 260;
p.wave.setAttribute('r', T / 2 + a * 40);
p.wave.setAttribute('opacity', Math.max(0, 0.7 - a * 1.4));
[p.x - run, p.x + run].forEach(function (rx, i) {
p.runs[i].setAttribute('cx', rx);
p.runs[i].setAttribute('opacity', rx > 2 && rx < W - 2 ? 1 - a / 0.7 : 0);
});
return true;
});
var latest = pulses[pulses.length - 1];
var age = latest ? (now - latest.t0) / 1000 : 9;
thumb.setAttribute('r', (T / 2) * (1 + (latest && latest.strong ? 0.38 : 0.22) * Math.exp(-age / 0.09)));
raf = requestAnimationFrame(step);
}
function update() {
var x = at(+input.value);
fill.setAttribute('x2', x);
thumb.setAttribute('cx', x);
ring.setAttribute('cx', x);
label.textContent = input.value + ' BPM';
input.setAttribute('aria-valuetext', input.value + ' beats per minute');
}
// Only keeps time while on screen, and not at all under reduced motion.
var io = new IntersectionObserver(function (entries) {
cancelAnimationFrame(raf);
if (entries[0].isIntersecting && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
last = performance.now();
raf = requestAnimationFrame(step);
}
});
io.observe(root);
input.addEventListener('input', update);
return function () {
io.disconnect();
cancelAnimationFrame(raf);
input.removeEventListener('input', update);
};
}
document.querySelectorAll('.slider-tempo').forEach(initSliderTempo);Goal: Create a "Tempo" slider.
Visual behavior: A tempo slider whose thumb keeps time at the tempo you set: every beat it kicks and sends a ring and a pulse running both ways down the track, with every fourth beat accented.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- A requestAnimationFrame loop counts beats at 60000 over the BPM milliseconds, catching up a missed beat rather than drifting. Each beat adds a ring that grows and fades from the thumb and two dots that run out along the track; a beat counter of four dots lights the current beat.
- The thumb's radius kicks up and decays exponentially after each beat, harder on the downbeat. The loop pauses off screen through an IntersectionObserver.
Accessibility: A native range input with an aria-label and an aria-valuetext in beats per minute; keyboard focus rings the thumb. Under prefers-reduced-motion nothing pulses.
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 "Tempo" slider.
Visual behavior: A tempo slider whose thumb keeps time at the tempo you set: every beat it kicks and sends a ring and a pulse running both ways down the track, with every fourth beat accented.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- A requestAnimationFrame loop counts beats at 60000 over the BPM milliseconds, catching up a missed beat rather than drifting. Each beat adds a ring that grows and fades from the thumb and two dots that run out along the track; a beat counter of four dots lights the current beat.
- The thumb's radius kicks up and decays exponentially after each beat, harder on the downbeat. The loop pauses off screen through an IntersectionObserver.
Accessibility: A native range input with an aria-label and an aria-valuetext in beats per minute; keyboard focus rings the thumb. Under prefers-reduced-motion nothing pulses.
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 tempo slider whose thumb keeps time at the tempo you set: every beat it kicks and sends a ring and a pulse running both ways down the track, with every fourth beat accented.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, loop, ambient
- Techniques
- svg, raf
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label and an aria-valuetext in beats per minute; keyboard focus rings the thumb. Under prefers-reduced-motion nothing pulses.