Strobe
import { useEffect, useRef, useState } from 'react';
const W = 240;
const T = 18;
const MID = 20;
const FLASH = 38;
const css = `
.slider-strobe { position: relative; width: ${W}px; height: 62px; }
.slider-strobe__track, .slider-strobe__fill { position: absolute; left: 0; top: ${MID - 2}px; height: 4px; border-radius: 2px; }
.slider-strobe__track { width: ${W}px; background: light-dark(#dbdde1, #2e3035); }
.slider-strobe__fill { width: var(--slider-strobe-x); background: light-dark(#42454c, #e5e6e9); }
.slider-strobe__thumb, .slider-strobe__ghost {
position: absolute;
top: ${MID - T / 2}px;
width: ${T}px;
height: ${T}px;
margin-left: ${-T / 2}px;
border-radius: 50%;
}
.slider-strobe__thumb {
left: var(--slider-strobe-x);
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 1px 3px light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.5));
}
.slider-strobe:has(.slider-strobe__input:focus-visible) .slider-strobe__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-strobe__ghost {
border: 1.5px solid light-dark(#50535b, #c6c8cd);
animation: slider-strobe-fade 0.55s cubic-bezier(0.3, 0, 0.6, 1) forwards;
}
@keyframes slider-strobe-fade {
from { opacity: 0.75; transform: scale(1); }
to { opacity: 0; transform: scale(0.55); }
}
.slider-strobe__value { position: absolute; left: 0; right: 0; top: 44px; display: flex; justify-content: space-between; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-strobe__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-strobe__input { position: absolute; left: 0; top: 0; width: 100%; height: 40px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-strobe__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${T}px; height: 40px; }
.slider-strobe__input::-moz-range-thumb { width: ${T}px; height: 40px; border: 0; }
`;
type Ghost = { id: number; x: number };
export function StrobeSlider() {
const [v, setV] = useState(36);
const target = T / 2 + (v / 100) * (W - T);
const [x, setX] = useState(target);
const [ghosts, setGhosts] = useState<Ghost[]>([]);
const st = useRef({ x: target, v: 0, raf: 0, flash: 0, at: target, id: 0 });
// The thumb runs on a spring; every few frames, like a strobe firing, it leaves a copy where it was.
useEffect(() => {
const q = st.current;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
q.x = target;
setX(target);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.v += (-170 * (q.x - target) - 24 * q.v) * dt;
q.x += q.v * dt;
if (now - q.flash > FLASH && Math.abs(q.x - q.at) > 3) {
q.flash = now;
q.at = q.x;
const g = { id: ++q.id, x: q.x };
setGhosts((gs) => [...gs, g]);
}
if (Math.abs(q.x - target) < 0.05 && Math.abs(q.v) < 0.5) {
q.x = target;
q.v = 0;
setX(target);
return;
}
setX(q.x);
q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [target]);
return (
<>
<style>
{css}
</style>
<div className="slider-strobe" style={{ '--slider-strobe-x': `${x}px` } as React.CSSProperties}>
<span className="slider-strobe__track" aria-hidden="true" />
<span className="slider-strobe__fill" aria-hidden="true" />
{ghosts.map((g) => (
<span key={g.id} className="slider-strobe__ghost" style={{ left: g.x }} onAnimationEnd={() => setGhosts((gs) => gs.filter((o) => o.id !== g.id))} aria-hidden="true" />
))}
<span className="slider-strobe__thumb" aria-hidden="true" />
<p className="slider-strobe__value" aria-hidden="true">
<span>Speed</span>
<b>{v}%</b>
</p>
<input className="slider-strobe__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Speed" aria-valuetext={`${v}%`} />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderStrobe(el) on the mounted element instead.
<div class="slider-strobe" style="--slider-strobe-x:88.92px">
<span class="slider-strobe__track" aria-hidden="true"></span>
<span class="slider-strobe__fill" aria-hidden="true"></span>
<span class="slider-strobe__thumb" aria-hidden="true"></span>
<p class="slider-strobe__value" aria-hidden="true">
<span>Speed</span>
<b>36%</b>
</p>
<input class="slider-strobe__input" type="range" min="0" max="100" aria-label="Speed" aria-valuetext="36%" value="36"/>
</div>.slider-strobe { position: relative; width: 240px; height: 62px; }
.slider-strobe__track, .slider-strobe__fill { position: absolute; left: 0; top: 18px; height: 4px; border-radius: 2px; }
.slider-strobe__track { width: 240px; background: light-dark(#dbdde1, #2e3035); }
.slider-strobe__fill { width: var(--slider-strobe-x); background: light-dark(#42454c, #e5e6e9); }
.slider-strobe__thumb, .slider-strobe__ghost {
position: absolute;
top: 11px;
width: 18px;
height: 18px;
margin-left: -9px;
border-radius: 50%;
}
.slider-strobe__thumb {
left: var(--slider-strobe-x);
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 1px 3px light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.5));
}
.slider-strobe:has(.slider-strobe__input:focus-visible) .slider-strobe__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-strobe__ghost {
border: 1.5px solid light-dark(#50535b, #c6c8cd);
animation: slider-strobe-fade 0.55s cubic-bezier(0.3, 0, 0.6, 1) forwards;
}
@keyframes slider-strobe-fade {
from { opacity: 0.75; transform: scale(1); }
to { opacity: 0; transform: scale(0.55); }
}
.slider-strobe__value { position: absolute; left: 0; right: 0; top: 44px; display: flex; justify-content: space-between; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-strobe__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-strobe__input { position: absolute; left: 0; top: 0; width: 100%; height: 40px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-strobe__input::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 40px; }
.slider-strobe__input::-moz-range-thumb { width: 18px; height: 40px; border: 0; }function initSliderStrobe(root) {
var W = 240;
var T = 18;
var FLASH = 38;
var input = root.querySelector('.slider-strobe__input');
var thumb = root.querySelector('.slider-strobe__thumb');
var label = root.querySelector('.slider-strobe__value b');
var x = target();
var v = 0;
var flash = 0;
var at = x;
var raf = 0;
function target() { return T / 2 + (+input.value / 100) * (W - T); }
function draw() { root.style.setProperty('--slider-strobe-x', x + 'px'); }
// Like a strobe firing every few frames, the moving thumb leaves a copy where it was, which fades.
function ghost() {
var g = document.createElement('span');
g.className = 'slider-strobe__ghost';
g.setAttribute('aria-hidden', 'true');
g.style.left = x + 'px';
g.addEventListener('animationend', function () { g.remove(); });
root.insertBefore(g, thumb);
}
function run() {
cancelAnimationFrame(raf);
var goal = target();
label.textContent = input.value + '%';
input.setAttribute('aria-valuetext', input.value + '%');
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
x = goal;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
v += (-170 * (x - goal) - 24 * v) * dt;
x += v * dt;
if (now - flash > FLASH && Math.abs(x - at) > 3) {
flash = now;
at = x;
ghost();
}
if (Math.abs(x - goal) < 0.05 && Math.abs(v) < 0.5) {
x = goal;
v = 0;
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('input', run);
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', run);
};
}
document.querySelectorAll('.slider-strobe').forEach(initSliderStrobe);Goal: Create a "Strobe" slider.
Visual behavior: A slider whose thumb leaves fading afterimages as it moves, like a strobe photograph: spread far apart on a fast drag and bunched up on a slow one.
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.
- The drawn thumb follows the input on a spring. Like a strobe firing at a fixed rate, every 38ms while it moves it drops a ring where it is, so the spacing of the rings shows its speed.
- Each ring is an element with a keyframe animation that fades and shrinks it, and it removes itself on animationend.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the thumb. Under prefers-reduced-motion the thumb moves without the spring and leaves no afterimages.
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 "Strobe" slider.
Visual behavior: A slider whose thumb leaves fading afterimages as it moves, like a strobe photograph: spread far apart on a fast drag and bunched up on a slow one.
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.
- The drawn thumb follows the input on a spring. Like a strobe firing at a fixed rate, every 38ms while it moves it drops a ring where it is, so the spacing of the rings shows its speed.
- Each ring is an element with a keyframe animation that fades and shrinks it, and it removes itself on animationend.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the thumb. Under prefers-reduced-motion the thumb moves without the spring and leaves no afterimages.
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 slider whose thumb leaves fading afterimages as it moves, like a strobe photograph: spread far apart on a fast drag and bunched up on a slow one.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring, keyframe
- Techniques
- spring, raf, keyframes
- 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 percent; keyboard focus rings the thumb. Under prefers-reduced-motion the thumb moves without the spring and leaves no afterimages.