Pluck
import { useEffect, useRef, useState } from 'react';
const W = 240;
const Y = 34;
const PEG = 5;
const css = `
.slider-pluck { position: relative; width: ${W}px; height: 68px; }
.slider-pluck__svg { display: block; width: ${W}px; height: 68px; overflow: visible; }
.slider-pluck__on { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 2.5; stroke-linecap: round; }
.slider-pluck__off { stroke: light-dark(#cbced3, #393c41); stroke-width: 2; stroke-linecap: round; }
.slider-pluck__peg { fill: light-dark(#6d7179, #7d8087); }
.slider-pluck__bead { fill: light-dark(#373a40, #f1f2f3); filter: drop-shadow(0 2px 3px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5))); }
.slider-pluck__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-pluck:has(.slider-pluck__input:focus-visible) .slider-pluck__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-pluck__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: grab; -webkit-appearance: none; appearance: none; background: none; }
.slider-pluck__input:active { cursor: grabbing; }
.slider-pluck__input::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 24px; }
.slider-pluck__input::-moz-range-thumb { width: 24px; height: 24px; border: 0; }
`;
export function PluckSlider() {
const [v, setV] = useState(40);
const [sag, setSag] = useState(0);
// The bead's height on the string as a lightly damped spring: pressing bends it, letting go twangs it.
const sim = useRef({ y: 0, vy: 0, target: 0, raf: 0, still: false });
useEffect(() => {
sim.current.still = matchMedia('(prefers-reduced-motion: reduce)').matches;
return () => cancelAnimationFrame(sim.current.raf);
}, []);
const run = () => {
const s = sim.current;
if (s.still) {
s.y = s.target;
setSag(s.y);
return;
}
if (s.raf) return;
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
for (let i = 0; i < 4; i++) {
const a = -1200 * (s.y - s.target) - 7 * s.vy;
s.vy += (a * dt) / 4;
s.y += (s.vy * dt) / 4;
}
if (Math.abs(s.y - s.target) < 0.04 && Math.abs(s.vy) < 0.6) {
s.y = s.target;
s.vy = 0;
s.raf = 0;
setSag(s.y);
return;
}
setSag(s.y);
s.raf = requestAnimationFrame(step);
};
s.raf = requestAnimationFrame(step);
};
const press = (e: React.PointerEvent) => {
const y0 = e.clientY;
sim.current.target = 7;
run();
// Pull up or down while holding to bend the string further.
const move = (m: PointerEvent) => {
sim.current.target = Math.max(-16, Math.min(18, 7 + (m.clientY - y0) * 0.8));
run();
};
const up = () => {
sim.current.target = 0;
run();
removeEventListener('pointermove', move);
removeEventListener('pointerup', up);
removeEventListener('pointercancel', up);
};
addEventListener('pointermove', move);
addEventListener('pointerup', up);
addEventListener('pointercancel', up);
};
const bx = 20 + (v / 100) * (W - 40);
const by = Y + sag;
return (
<>
<style>
{css}
</style>
<div className="slider-pluck">
<svg className="slider-pluck__svg" aria-hidden="true">
<line className="slider-pluck__off" x1={bx} y1={by} x2={W - PEG} y2={Y} />
<line className="slider-pluck__on" x1={PEG} y1={Y} x2={bx} y2={by} />
<circle className="slider-pluck__peg" cx={PEG} cy={Y} r={3.5} />
<circle className="slider-pluck__peg" cx={W - PEG} cy={Y} r={3.5} />
<circle className="slider-pluck__bead" cx={bx} cy={by} r={9} />
<circle className="slider-pluck__ring" cx={bx} cy={by} r={13} />
</svg>
<input
className="slider-pluck__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
onPointerDown={press}
onKeyDown={(e) => {
// A light pluck on every key step.
if (/^Arrow|^Page|^Home$|^End$/.test(e.key) && !sim.current.still) {
sim.current.vy += 70;
run();
}
}}
aria-label="Tension"
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderPluck(el) on the mounted element instead.
<div class="slider-pluck">
<svg class="slider-pluck__svg" aria-hidden="true">
<line class="slider-pluck__off" x1="100" y1="34" x2="235" y2="34"></line>
<line class="slider-pluck__on" x1="5" y1="34" x2="100" y2="34"></line>
<circle class="slider-pluck__peg" cx="5" cy="34" r="3.5"></circle>
<circle class="slider-pluck__peg" cx="235" cy="34" r="3.5"></circle>
<circle class="slider-pluck__bead" cx="100" cy="34" r="9"></circle>
<circle class="slider-pluck__ring" cx="100" cy="34" r="13"></circle>
</svg>
<input class="slider-pluck__input" type="range" min="0" max="100" aria-label="Tension" value="40"/>
</div>.slider-pluck { position: relative; width: 240px; height: 68px; }
.slider-pluck__svg { display: block; width: 240px; height: 68px; overflow: visible; }
.slider-pluck__on { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 2.5; stroke-linecap: round; }
.slider-pluck__off { stroke: light-dark(#cbced3, #393c41); stroke-width: 2; stroke-linecap: round; }
.slider-pluck__peg { fill: light-dark(#6d7179, #7d8087); }
.slider-pluck__bead { fill: light-dark(#373a40, #f1f2f3); filter: drop-shadow(0 2px 3px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5))); }
.slider-pluck__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-pluck:has(.slider-pluck__input:focus-visible) .slider-pluck__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-pluck__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: grab; -webkit-appearance: none; appearance: none; background: none; }
.slider-pluck__input:active { cursor: grabbing; }
.slider-pluck__input::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 24px; }
.slider-pluck__input::-moz-range-thumb { width: 24px; height: 24px; border: 0; }function initSliderPluck(root) {
var W = 240, Y = 34;
var input = root.querySelector('.slider-pluck__input');
var on = root.querySelector('.slider-pluck__on');
var off = root.querySelector('.slider-pluck__off');
var circles = root.querySelectorAll('.slider-pluck__bead, .slider-pluck__ring');
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
// The bead's height on the string as a lightly damped spring: pressing bends it, letting go twangs it.
var s = { y: 0, vy: 0, target: 0, raf: 0 };
var y0 = 0;
function draw() {
var bx = 20 + (input.value / 100) * (W - 40);
var by = Y + s.y;
on.setAttribute('x2', bx);
on.setAttribute('y2', by);
off.setAttribute('x1', bx);
off.setAttribute('y1', by);
circles.forEach(function (c) {
c.setAttribute('cx', bx);
c.setAttribute('cy', by);
});
}
function run() {
if (still) {
s.y = s.target;
draw();
return;
}
if (s.raf) return;
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
for (var i = 0; i < 4; i++) {
var a = -1200 * (s.y - s.target) - 7 * s.vy;
s.vy += (a * dt) / 4;
s.y += (s.vy * dt) / 4;
}
if (Math.abs(s.y - s.target) < 0.04 && Math.abs(s.vy) < 0.6) {
s.y = s.target;
s.vy = 0;
s.raf = 0;
draw();
return;
}
draw();
s.raf = requestAnimationFrame(step);
}
s.raf = requestAnimationFrame(step);
}
// Pull up or down while holding to bend the string further.
function move(e) {
s.target = Math.max(-16, Math.min(18, 7 + (e.clientY - y0) * 0.8));
run();
}
function up() {
s.target = 0;
run();
removeEventListener('pointermove', move);
removeEventListener('pointerup', up);
removeEventListener('pointercancel', up);
}
function down(e) {
y0 = e.clientY;
s.target = 7;
run();
addEventListener('pointermove', move);
addEventListener('pointerup', up);
addEventListener('pointercancel', up);
}
// A light pluck on every key step.
function key(e) {
if (/^Arrow|^Page|^Home$|^End$/.test(e.key) && !still) {
s.vy += 70;
run();
}
}
input.addEventListener('input', draw);
input.addEventListener('pointerdown', down);
input.addEventListener('keydown', key);
draw();
return function () {
cancelAnimationFrame(s.raf);
up();
input.removeEventListener('input', draw);
input.removeEventListener('pointerdown', down);
input.removeEventListener('keydown', key);
};
}
document.querySelectorAll('.slider-pluck').forEach(initSliderPluck);Goal: Create a "Pluck" slider.
Visual behavior: A tension slider drawn as a taut string with a bead for a thumb: press and the string bends under the bead, pull up or down to bend it further, and let go to make it twang.
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 string is two SVG lines meeting at the bead. The bead's offset from the string is a stiff, lightly damped spring run on requestAnimationFrame, so releasing it oscillates and dies away.
- Pointer down sets the spring's target to a small bend and a window pointermove adds the vertical pull; pointer up returns the target to zero. Each arrow-key step adds a little velocity: a light pluck.
Accessibility: A native range input with an aria-label; keyboard focus rings the bead. Under prefers-reduced-motion the string bends and straightens without vibrating.
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 "Pluck" slider.
Visual behavior: A tension slider drawn as a taut string with a bead for a thumb: press and the string bends under the bead, pull up or down to bend it further, and let go to make it twang.
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 string is two SVG lines meeting at the bead. The bead's offset from the string is a stiff, lightly damped spring run on requestAnimationFrame, so releasing it oscillates and dies away.
- Pointer down sets the spring's target to a small bend and a window pointermove adds the vertical pull; pointer up returns the target to zero. Each arrow-key step adds a little velocity: a light pluck.
Accessibility: A native range input with an aria-label; keyboard focus rings the bead. Under prefers-reduced-motion the string bends and straightens without vibrating.
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 tension slider drawn as a taut string with a bead for a thumb: press and the string bends under the bead, pull up or down to bend it further, and let go to make it twang.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, spring, click
- Techniques
- svg, spring, raf
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label; keyboard focus rings the bead. Under prefers-reduced-motion the string bends and straightens without vibrating.