Shuttle
import { useEffect, useRef, useState } from 'react';
const W = 240;
const REACH = 56;
const LENGTH = 120;
const FASTEST = 16;
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
const time = (s: number) => `${Math.floor(s / 60)}:${(s % 60).toFixed(1).padStart(4, '0')}`;
const css = `
.slider-shuttle { position: relative; width: ${W}px; height: 84px; touch-action: none; cursor: grab; outline: none; user-select: none; -webkit-user-select: none; }
.slider-shuttle--held { cursor: grabbing; }
.slider-shuttle__head { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-shuttle__value { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-shuttle__groove {
position: absolute;
left: 0;
top: 26px;
width: ${W}px;
height: 34px;
border-radius: 17px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 1px 3px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5)), inset 0 0 0 1px light-dark(#ebedf0, #232529);
}
.slider-shuttle__centre { position: absolute; left: ${W / 2 - 0.5}px; top: 4px; width: 1px; height: 26px; background: light-dark(#dbdde1, #2e3035); }
.slider-shuttle__chev { position: absolute; top: 11px; width: 8px; height: 12px; color: light-dark(#dbdde1, #2e3035); transition: color 0.12s; }
.slider-shuttle__chev--lit { color: light-dark(#50535b, #c6c8cd); }
.slider-shuttle__handle {
position: absolute;
left: ${W / 2 - 22}px;
top: 4px;
width: 44px;
height: 26px;
border-radius: 13px;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.5)), 0 4px 10px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4));
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
/* Let go and it springs back past centre and settles. */
transition: transform 0.55s cubic-bezier(0.3, 1.7, 0.45, 1);
}
.slider-shuttle--held .slider-shuttle__handle { transition: none; }
.slider-shuttle__grip { width: 1.5px; height: 10px; border-radius: 1px; background: light-dark(#6d7179, #7d8087); }
.slider-shuttle:focus-visible .slider-shuttle__handle { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-shuttle__range { position: absolute; left: 0; top: 74px; width: ${W}px; height: 1px; background: light-dark(#dbdde1, #2e3035); }
.slider-shuttle__played { position: absolute; left: 0; top: 0; height: 1px; width: var(--slider-shuttle-at); background: light-dark(#50535b, #c6c8cd); }
@media (prefers-reduced-motion: reduce) {
.slider-shuttle__handle { transition: transform 0.15s ease-out; }
}
`;
export function ShuttleSlider() {
const [t, setT] = useState(42.6);
const [push, setPush] = useState(0);
const [held, setHeld] = useState(false);
const drag = useRef<number | null>(null);
const live = useRef(push);
live.current = push;
// While held, time runs at a speed that climbs steeply with how far the handle is pushed.
useEffect(() => {
if (!held) return;
let raf = 0;
let last = performance.now();
const step = (now: number) => {
const dt = (now - last) / 1000;
last = now;
const f = live.current / REACH;
setT((t) => clamp(t + Math.sign(f) * f * f * FASTEST * dt, 0, LENGTH));
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [held]);
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = e.clientX;
setHeld(true);
};
const onPointerMove = (e: React.PointerEvent) => {
if (drag.current === null) return;
setPush(clamp(e.clientX - drag.current, -REACH, REACH));
};
const onPointerUp = () => {
drag.current = null;
setHeld(false);
setPush(0);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const step = e.shiftKey ? 10 : 1;
const next = { ArrowRight: t + step, ArrowUp: t + step, ArrowLeft: t - step, ArrowDown: t - step, Home: 0, End: LENGTH }[e.key];
if (next === undefined) return;
e.preventDefault();
setT(clamp(Math.round(next * 10) / 10, 0, LENGTH));
};
const f = push / REACH;
const shown = Math.round(t * 10) / 10;
return (
<>
<style>
{css}
</style>
<div
className={`slider-shuttle${held ? ' slider-shuttle--held' : ''}`}
role="slider"
tabIndex={0}
aria-label="Playhead"
aria-valuemin={0}
aria-valuemax={LENGTH}
aria-valuenow={shown}
aria-valuetext={time(shown)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
style={{ '--slider-shuttle-at': `${(t / LENGTH) * 100}%` } as React.CSSProperties}
>
<div className="slider-shuttle__head" aria-hidden="true">
<span>{held && push !== 0 ? `${push < 0 ? 'Rewind' : 'Forward'} ${Math.max(1, Math.round(f * f * FASTEST))}×` : 'Push to shuttle'}</span>
<span className="slider-shuttle__value">{time(shown)}</span>
</div>
<div className="slider-shuttle__groove" aria-hidden="true">
<span className="slider-shuttle__centre" />
{[-1, 1].flatMap((side) =>
[0, 1, 2].map((j) => (
<svg
key={`${side}${j}`}
className={`slider-shuttle__chev${Math.sign(push) === side && Math.abs(f) > (j + 1) / 4 ? ' slider-shuttle__chev--lit' : ''}`}
style={{ left: W / 2 + side * (84 + j * 12) - 4 }}
viewBox="0 0 8 12"
>
<path
d={side > 0 ? 'M2 1.5 6.5 6 2 10.5' : 'M6 1.5 1.5 6 6 10.5'}
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)),
)}
<span className="slider-shuttle__handle" style={{ transform: `translateX(${push}px)` }}>
<span className="slider-shuttle__grip" />
<span className="slider-shuttle__grip" />
<span className="slider-shuttle__grip" />
</span>
</div>
<span className="slider-shuttle__range" aria-hidden="true">
<span className="slider-shuttle__played" />
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderShuttle(el) on the mounted element instead.
<div class="slider-shuttle" role="slider" tabindex="0" aria-label="Playhead" aria-valuemin="0" aria-valuemax="120" aria-valuenow="42.6" aria-valuetext="0:42.6" style="--slider-shuttle-at:35.5%">
<div class="slider-shuttle__head" aria-hidden="true">
<span>Push to shuttle</span>
<span class="slider-shuttle__value">0:42.6</span>
</div>
<div class="slider-shuttle__groove" aria-hidden="true">
<span class="slider-shuttle__centre"></span>
<svg class="slider-shuttle__chev" style="left:32px" viewBox="0 0 8 12">
<path d="M6 1.5 1.5 6 6 10.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<svg class="slider-shuttle__chev" style="left:20px" viewBox="0 0 8 12">
<path d="M6 1.5 1.5 6 6 10.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<svg class="slider-shuttle__chev" style="left:8px" viewBox="0 0 8 12">
<path d="M6 1.5 1.5 6 6 10.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<svg class="slider-shuttle__chev" style="left:200px" viewBox="0 0 8 12">
<path d="M2 1.5 6.5 6 2 10.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<svg class="slider-shuttle__chev" style="left:212px" viewBox="0 0 8 12">
<path d="M2 1.5 6.5 6 2 10.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<svg class="slider-shuttle__chev" style="left:224px" viewBox="0 0 8 12">
<path d="M2 1.5 6.5 6 2 10.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<span class="slider-shuttle__handle" style="transform:translateX(0px)">
<span class="slider-shuttle__grip"></span>
<span class="slider-shuttle__grip"></span>
<span class="slider-shuttle__grip"></span>
</span>
</div>
<span class="slider-shuttle__range" aria-hidden="true">
<span class="slider-shuttle__played"></span>
</span>
</div>.slider-shuttle { position: relative; width: 240px; height: 84px; touch-action: none; cursor: grab; outline: none; user-select: none; -webkit-user-select: none; }
.slider-shuttle--held { cursor: grabbing; }
.slider-shuttle__head { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-shuttle__value { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-shuttle__groove {
position: absolute;
left: 0;
top: 26px;
width: 240px;
height: 34px;
border-radius: 17px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 1px 3px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5)), inset 0 0 0 1px light-dark(#ebedf0, #232529);
}
.slider-shuttle__centre { position: absolute; left: 119.5px; top: 4px; width: 1px; height: 26px; background: light-dark(#dbdde1, #2e3035); }
.slider-shuttle__chev { position: absolute; top: 11px; width: 8px; height: 12px; color: light-dark(#dbdde1, #2e3035); transition: color 0.12s; }
.slider-shuttle__chev--lit { color: light-dark(#50535b, #c6c8cd); }
.slider-shuttle__handle {
position: absolute;
left: 98px;
top: 4px;
width: 44px;
height: 26px;
border-radius: 13px;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.5)), 0 4px 10px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4));
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
/* Let go and it springs back past centre and settles. */
transition: transform 0.55s cubic-bezier(0.3, 1.7, 0.45, 1);
}
.slider-shuttle--held .slider-shuttle__handle { transition: none; }
.slider-shuttle__grip { width: 1.5px; height: 10px; border-radius: 1px; background: light-dark(#6d7179, #7d8087); }
.slider-shuttle:focus-visible .slider-shuttle__handle { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-shuttle__range { position: absolute; left: 0; top: 74px; width: 240px; height: 1px; background: light-dark(#dbdde1, #2e3035); }
.slider-shuttle__played { position: absolute; left: 0; top: 0; height: 1px; width: var(--slider-shuttle-at); background: light-dark(#50535b, #c6c8cd); }
@media (prefers-reduced-motion: reduce) {
.slider-shuttle__handle { transition: transform 0.15s ease-out; }
}function initSliderShuttle(root) {
var REACH = 56;
var LENGTH = 120;
var FASTEST = 16;
var handle = root.querySelector('.slider-shuttle__handle');
var head = root.querySelector('.slider-shuttle__head span');
var label = root.querySelector('.slider-shuttle__value');
var chevs = root.querySelectorAll('.slider-shuttle__chev');
var t = +root.getAttribute('aria-valuenow');
var push = 0;
var drag = null;
var raf = 0;
function clamp(n, lo, hi) { return Math.min(hi, Math.max(lo, n)); }
function time(s) {
var sec = (s % 60).toFixed(1);
return Math.floor(s / 60) + ':' + (sec.length < 4 ? '0' : '') + sec;
}
function render() {
var f = push / REACH;
var shown = Math.round(t * 10) / 10;
head.textContent = drag && push !== 0 ? (push < 0 ? 'Rewind ' : 'Forward ') + Math.max(1, Math.round(f * f * FASTEST)) + '×' : 'Push to shuttle';
label.textContent = time(shown);
root.setAttribute('aria-valuenow', shown);
root.setAttribute('aria-valuetext', time(shown));
root.style.setProperty('--slider-shuttle-at', (t / LENGTH) * 100 + '%');
handle.style.transform = 'translateX(' + push + 'px)';
// Three chevrons each way light up in turn as the handle is pushed further.
chevs.forEach(function (c, i) {
var side = i < 3 ? -1 : 1;
c.classList.toggle('slider-shuttle__chev--lit', Math.sign(push) === side && Math.abs(f) > ((i % 3) + 1) / 4);
});
}
// While held, time runs at a speed that climbs steeply with how far the handle is pushed.
function run(now) {
var dt = (now - run.last) / 1000;
run.last = now;
var f = push / REACH;
t = clamp(t + Math.sign(f) * f * f * FASTEST * dt, 0, LENGTH);
render();
raf = requestAnimationFrame(run);
}
function down(e) {
if (e.button !== 0) return;
root.setPointerCapture(e.pointerId);
drag = e.clientX;
root.classList.add('slider-shuttle--held');
run.last = performance.now();
raf = requestAnimationFrame(run);
}
function move(e) {
if (drag === null) return;
push = clamp(e.clientX - drag, -REACH, REACH);
render();
}
function up() {
if (drag === null) return;
drag = null;
push = 0;
cancelAnimationFrame(raf);
// Let go and the handle springs back past centre and settles (the CSS transition).
root.classList.remove('slider-shuttle--held');
render();
}
function onKeyDown(e) {
var step = e.shiftKey ? 10 : 1;
var next = { ArrowRight: t + step, ArrowUp: t + step, ArrowLeft: t - step, ArrowDown: t - step, Home: 0, End: LENGTH }[e.key];
if (next === undefined) return;
e.preventDefault();
t = clamp(Math.round(next * 10) / 10, 0, LENGTH);
render();
}
root.addEventListener('pointerdown', down);
root.addEventListener('pointermove', move);
root.addEventListener('pointerup', up);
root.addEventListener('pointercancel', up);
root.addEventListener('keydown', onKeyDown);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('pointerdown', down);
root.removeEventListener('pointermove', move);
root.removeEventListener('pointerup', up);
root.removeEventListener('pointercancel', up);
root.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.slider-shuttle').forEach(initSliderShuttle);Goal: Create a "Shuttle" slider.
Visual behavior: A spring-loaded shuttle from an edit suite: push the handle either way and the time runs faster the further you push it, with chevrons lighting up; let go and it springs back to centre.
Technique:
- The control is a focusable element with role="slider", aria-valuemin, aria-valuemax and aria-valuenow, driven by pointer events with pointer capture and by the arrow keys, Page Up/Down, Home and End. The drawing is aria-hidden.
- Pressing anywhere and dragging displaces the handle up to 56px; while held, a requestAnimationFrame loop advances the time by the square of the displacement times 16 seconds a second, so small pushes creep and full pushes race.
- Releasing zeroes the displacement and the handle returns on a transition that overshoots centre once. Three chevrons each way light in turn with the push, and a fine line below shows the position in the clip.
Accessibility: role="slider" with an aria-label, aria-valuenow in seconds and an aria-valuetext as a timecode; the arrow keys step by a second, Shift by ten, with Home and End. Keyboard focus rings the handle. Under prefers-reduced-motion the handle returns without the overshoot.
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 "Shuttle" slider.
Visual behavior: A spring-loaded shuttle from an edit suite: push the handle either way and the time runs faster the further you push it, with chevrons lighting up; let go and it springs back to centre.
Technique:
- The control is a focusable element with role="slider", aria-valuemin, aria-valuemax and aria-valuenow, driven by pointer events with pointer capture and by the arrow keys, Page Up/Down, Home and End. The drawing is aria-hidden.
- Pressing anywhere and dragging displaces the handle up to 56px; while held, a requestAnimationFrame loop advances the time by the square of the displacement times 16 seconds a second, so small pushes creep and full pushes race.
- Releasing zeroes the displacement and the handle returns on a transition that overshoots centre once. Three chevrons each way light in turn with the push, and a fine line below shows the position in the clip.
Accessibility: role="slider" with an aria-label, aria-valuenow in seconds and an aria-valuetext as a timecode; the arrow keys step by a second, Shift by ten, with Home and End. Keyboard focus rings the handle. Under prefers-reduced-motion the handle returns without the overshoot.
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 spring-loaded shuttle from an edit suite: push the handle either way and the time runs faster the further you push it, with chevrons lighting up; let go and it springs back to centre.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring, continuous
- Techniques
- raf, event position, css transition
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
role="slider" with an aria-label, aria-valuenow in seconds and an aria-valuetext as a timecode; the arrow keys step by a second, Shift by ten, with Home and End. Keyboard focus rings the handle. Under prefers-reduced-motion the handle returns without the overshoot.