Squiggle
import { useEffect, useRef, useState } from 'react';
const W = 240;
const MID = 22;
const GAP = 8;
const AMP = 3.5;
const LENGTH = 210;
const css = `
.slider-squiggle { position: relative; width: ${W}px; }
.slider-squiggle__svg { display: block; width: ${W}px; height: 44px; overflow: visible; }
.slider-squiggle__wave { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; }
.slider-squiggle__rest { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 4; stroke-linecap: round; }
.slider-squiggle__handle {
position: absolute;
top: ${MID - 14}px;
left: calc(var(--slider-squiggle-x) * 1px - 3px);
width: 6px;
height: 28px;
border-radius: 3px;
background: light-dark(#373a40, #f1f2f3);
transition: transform 0.4s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-squiggle:has(.slider-squiggle__input:active) .slider-squiggle__handle { transform: scaleY(1.28); }
.slider-squiggle:has(.slider-squiggle__input:focus-visible) .slider-squiggle__handle { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-squiggle__times { display: flex; justify-content: space-between; margin-top: 2px; font-size: 12px; font-variant-numeric: tabular-nums; color: light-dark(#5f626a, #a7abb2); }
.slider-squiggle__input { position: absolute; left: 0; top: 0; width: 100%; height: 44px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-squiggle__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 44px; }
.slider-squiggle__input::-moz-range-thumb { width: 16px; height: 44px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-squiggle__handle { transition: none; }
}
`;
const clock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
const spoken = (s: number) => `${Math.floor(s / 60)} min ${s % 60} s`;
/** The played part as a sine that eases to flat at both ends, so it meets the handle level. */
function wave(end: number, amp: number, phase: number) {
if (end < 4) return '';
let d = '';
for (let x = 2; ; x += 1.5) {
const at = Math.min(x, end);
const ease = Math.min(1, (at - 2) / 8, (end - at) / 12);
const y = MID + amp * Math.sin((Math.max(0, ease) * Math.PI) / 2) * Math.sin(at / 3.6 - phase);
d += `${d ? 'L' : 'M'}${at.toFixed(1)} ${y.toFixed(2)}`;
if (at === end) return d;
}
}
export function SquiggleSlider() {
const [s, setS] = useState(84);
const path = useRef<SVGPathElement>(null);
const root = useRef<HTMLDivElement>(null);
const live = useRef({ x: 0, held: false, paint: () => {} });
const x = 4 + (s / LENGTH) * (W - 8);
live.current.x = x;
useEffect(() => {
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
let amp = AMP;
let phase = 0;
let last = performance.now();
let raf = 0;
const paint = () => {
const now = performance.now();
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const target = live.current.held ? 0 : AMP;
// Flattens while held so you can aim, ripples back on release.
amp = still ? target : amp + (target - amp) * (1 - Math.exp(-dt * 9));
if (!still) phase += dt * 3.2;
path.current?.setAttribute('d', wave(live.current.x - GAP, amp, phase));
};
live.current.paint = paint;
paint();
if (still) return;
const loop = () => {
paint();
raf = requestAnimationFrame(loop);
};
// Only animate while on screen.
const io = new IntersectionObserver(([e]) => {
cancelAnimationFrame(raf);
if (e.isIntersecting) {
last = performance.now();
raf = requestAnimationFrame(loop);
}
});
io.observe(root.current!);
return () => {
io.disconnect();
cancelAnimationFrame(raf);
};
}, []);
useEffect(() => live.current.paint(), [x]);
const hold = () => {
live.current.held = true;
live.current.paint();
const up = () => {
live.current.held = false;
live.current.paint();
removeEventListener('pointerup', up);
removeEventListener('pointercancel', up);
};
addEventListener('pointerup', up);
addEventListener('pointercancel', up);
};
return (
<>
<style>
{css}
</style>
<div ref={root} className="slider-squiggle" style={{ '--slider-squiggle-x': x } as React.CSSProperties}>
<svg className="slider-squiggle__svg" aria-hidden="true">
<path ref={path} className="slider-squiggle__wave" />
{x + GAP < W - 2 && <line className="slider-squiggle__rest" x1={x + GAP} y1={MID} x2={W - 2} y2={MID} />}
</svg>
<span className="slider-squiggle__handle" aria-hidden="true" />
<input
className="slider-squiggle__input"
type="range"
min={0}
max={LENGTH}
value={s}
onChange={(e) => setS(+e.target.value)}
onPointerDown={hold}
aria-label="Playback position"
aria-valuetext={`${spoken(s)} of ${spoken(LENGTH)}`}
/>
<div className="slider-squiggle__times" aria-hidden="true">
<span>{clock(s)}</span>
<span>{clock(LENGTH)}</span>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderSquiggle(el) on the mounted element instead.
<div class="slider-squiggle" style="--slider-squiggle-x:96.8">
<svg class="slider-squiggle__svg" aria-hidden="true">
<path class="slider-squiggle__wave"></path>
<line class="slider-squiggle__rest" x1="104.8" y1="22" x2="238" y2="22"></line>
</svg>
<span class="slider-squiggle__handle" aria-hidden="true"></span>
<input class="slider-squiggle__input" type="range" min="0" max="210" aria-label="Playback position" aria-valuetext="1 min 24 s of 3 min 30 s" value="84"/>
<div class="slider-squiggle__times" aria-hidden="true">
<span>1:24</span>
<span>3:30</span>
</div>
</div>.slider-squiggle { position: relative; width: 240px; }
.slider-squiggle__svg { display: block; width: 240px; height: 44px; overflow: visible; }
.slider-squiggle__wave { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; }
.slider-squiggle__rest { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 4; stroke-linecap: round; }
.slider-squiggle__handle {
position: absolute;
top: 8px;
left: calc(var(--slider-squiggle-x) * 1px - 3px);
width: 6px;
height: 28px;
border-radius: 3px;
background: light-dark(#373a40, #f1f2f3);
transition: transform 0.4s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-squiggle:has(.slider-squiggle__input:active) .slider-squiggle__handle { transform: scaleY(1.28); }
.slider-squiggle:has(.slider-squiggle__input:focus-visible) .slider-squiggle__handle { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-squiggle__times { display: flex; justify-content: space-between; margin-top: 2px; font-size: 12px; font-variant-numeric: tabular-nums; color: light-dark(#5f626a, #a7abb2); }
.slider-squiggle__input { position: absolute; left: 0; top: 0; width: 100%; height: 44px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-squiggle__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 44px; }
.slider-squiggle__input::-moz-range-thumb { width: 16px; height: 44px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-squiggle__handle { transition: none; }
}function initSliderSquiggle(root) {
var W = 240, MID = 22, GAP = 8, AMP = 3.5;
var input = root.querySelector('.slider-squiggle__input');
var wave = root.querySelector('.slider-squiggle__wave');
var rest = root.querySelector('.slider-squiggle__rest');
var time = root.querySelector('.slider-squiggle__times span');
var length = +input.max;
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var held = false, amp = AMP, phase = 0, last = performance.now(), raf = 0, x = 0;
function clock(s) { return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); }
function spoken(s) { return Math.floor(s / 60) + ' min ' + (s % 60) + ' s'; }
// The played part as a sine that eases to flat at both ends, so it meets the handle level.
function path(end) {
if (end < 4) return '';
var d = '';
for (var p = 2; ; p += 1.5) {
var at = Math.min(p, end);
var ease = Math.min(1, (at - 2) / 8, (end - at) / 12);
var y = MID + amp * Math.sin((Math.max(0, ease) * Math.PI) / 2) * Math.sin(at / 3.6 - phase);
d += (d ? 'L' : 'M') + at.toFixed(1) + ' ' + y.toFixed(2);
if (at === end) return d;
}
}
function paint() {
var now = performance.now();
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
var target = held ? 0 : AMP;
// Flattens while held so you can aim, ripples back on release.
amp = still ? target : amp + (target - amp) * (1 - Math.exp(-dt * 9));
if (!still) phase += dt * 3.2;
wave.setAttribute('d', path(x - GAP));
}
function update() {
var s = +input.value;
x = 4 + (s / length) * (W - 8);
root.style.setProperty('--slider-squiggle-x', x);
rest.setAttribute('x1', x + GAP);
rest.style.display = x + GAP < W - 2 ? '' : 'none';
time.textContent = clock(s);
input.setAttribute('aria-valuetext', spoken(s) + ' of ' + spoken(length));
paint();
}
function up() {
held = false;
paint();
removeEventListener('pointerup', up);
removeEventListener('pointercancel', up);
}
function down() {
held = true;
paint();
addEventListener('pointerup', up);
addEventListener('pointercancel', up);
}
function loop() {
paint();
raf = requestAnimationFrame(loop);
}
// Only animate while on screen.
var io = new IntersectionObserver(function (entries) {
cancelAnimationFrame(raf);
if (entries[0].isIntersecting && !still) {
last = performance.now();
raf = requestAnimationFrame(loop);
}
});
input.addEventListener('input', update);
input.addEventListener('pointerdown', down);
io.observe(root);
update();
return function () {
io.disconnect();
cancelAnimationFrame(raf);
up();
input.removeEventListener('input', update);
input.removeEventListener('pointerdown', down);
};
}
document.querySelectorAll('.slider-squiggle').forEach(initSliderSquiggle);Goal: Create a "Squiggle" slider.
Visual behavior: A playback scrubber whose played part is a travelling wave. Hold it and the wave settles flat so you can aim; let go and it ripples back, with the elapsed and total time below.
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 played part is one SVG path rebuilt every frame from a sine whose amplitude eases to zero at both ends, so it meets the handle level; the phase advances with time, so the wave travels.
- Pressing eases the amplitude down to flat and releasing eases it back. The loop only runs while the slider is on screen (IntersectionObserver).
Accessibility: A native range input in seconds with an aria-label and an aria-valuetext in minutes and seconds, so a screen reader announces the position rather than a bare number; keyboard focus outlines the handle. Under prefers-reduced-motion the wave holds still.
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 "Squiggle" slider.
Visual behavior: A playback scrubber whose played part is a travelling wave. Hold it and the wave settles flat so you can aim; let go and it ripples back, with the elapsed and total time below.
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 played part is one SVG path rebuilt every frame from a sine whose amplitude eases to zero at both ends, so it meets the handle level; the phase advances with time, so the wave travels.
- Pressing eases the amplitude down to flat and releasing eases it back. The loop only runs while the slider is on screen (IntersectionObserver).
Accessibility: A native range input in seconds with an aria-label and an aria-valuetext in minutes and seconds, so a screen reader announces the position rather than a bare number; keyboard focus outlines the handle. Under prefers-reduced-motion the wave holds still.
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 playback scrubber whose played part is a travelling wave. Hold it and the wave settles flat so you can aim; let go and it ripples back, with the elapsed and total time below.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, continuous, loop
- Techniques
- svg, raf, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input in seconds with an aria-label and an aria-valuetext in minutes and seconds, so a screen reader announces the position rather than a bare number; keyboard focus outlines the handle. Under prefers-reduced-motion the wave holds still.