Spring
import { useState } from 'react';
const COILS = 9;
const css = `
.slider-spring { display: grid; justify-items: center; gap: 10px; width: 230px; }
.slider-spring__view { position: relative; width: 230px; height: 76px; }
.slider-spring__view svg { display: block; width: 100%; height: 100%; overflow: visible; }
.slider-spring__wall { fill: light-dark(#dbdde1, #2e3035); }
.slider-spring__hatch { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.slider-spring__coil { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
.slider-spring__floor { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1; stroke-dasharray: 3 4; }
.slider-spring__weight { fill: light-dark(#42454c, #e5e6e9); }
.slider-spring__weight-top { fill: light-dark(#373a40, #f1f2f3); }
.slider-spring__label { fill: light-dark(#ebedf0, #232529); font-size: 9px; font-weight: 600; font-family: system-ui, sans-serif; }
.slider-spring__read { color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.slider-spring__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; font-variant-numeric: tabular-nums; }
.slider-spring:has(.slider-spring__input:focus-visible) .slider-spring__weight { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2; }
.slider-spring__input { position: absolute; inset: 0 0 0 18px; width: calc(100% - 18px); height: 100%; margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-spring__input::-webkit-slider-thumb { -webkit-appearance: none; width: 36px; height: 76px; }
.slider-spring__input::-moz-range-thumb { width: 36px; height: 76px; border: 0; }
`;
export function SpringSlider() {
const [v, setV] = useState(35);
const t = v / 100;
const end = 66 + t * 128; // where the spring meets the weight
const amp = 15 - t * 6;
const pts = [`20 38`];
for (let i = 1; i < COILS * 2; i++) pts.push(`${(20 + 6 + ((end - 32) * i) / (COILS * 2)).toFixed(1)} ${i % 2 ? 38 - amp : 38 + amp}`);
pts.push(`${(end - 6).toFixed(1)} 38`, `${end.toFixed(1)} 38`);
const newtons = Math.round(t * 120);
return (
<>
<style>
{css}
</style>
<div className="slider-spring">
<div className="slider-spring__view">
<svg viewBox="0 0 230 76" aria-hidden="true">
<line className="slider-spring__floor" x1="20" y1="58" x2="230" y2="58" />
<rect className="slider-spring__wall" x="8" y="8" width="12" height="60" rx="2" />
{[14, 26, 38, 50, 62].map((y) => (
<line key={y} className="slider-spring__hatch" x1="8" y1={y} x2="2" y2={y + 6} />
))}
<polyline className="slider-spring__coil" points={pts.join(' ')} />
<g transform={`translate(${end.toFixed(1)} 20)`}>
<rect className="slider-spring__weight" width="36" height="38" rx="5" />
<rect className="slider-spring__weight-top" width="36" height="6" rx="3" />
<text className="slider-spring__label" x="18" y="27" textAnchor="middle">
5 kg
</text>
</g>
</svg>
<input
className="slider-spring__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Tension"
aria-valuetext={`${newtons} newtons`}
/>
</div>
<span className="slider-spring__read" aria-hidden="true">
Tension <b>{newtons} N</b>
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderSpring(el) on the mounted element instead.
<div class="slider-spring">
<div class="slider-spring__view">
<svg viewBox="0 0 230 76" aria-hidden="true">
<line class="slider-spring__floor" x1="20" y1="58" x2="230" y2="58"></line>
<rect class="slider-spring__wall" x="8" y="8" width="12" height="60" rx="2"></rect>
<line class="slider-spring__hatch" x1="8" y1="14" x2="2" y2="20"></line>
<line class="slider-spring__hatch" x1="8" y1="26" x2="2" y2="32"></line>
<line class="slider-spring__hatch" x1="8" y1="38" x2="2" y2="44"></line>
<line class="slider-spring__hatch" x1="8" y1="50" x2="2" y2="56"></line>
<line class="slider-spring__hatch" x1="8" y1="62" x2="2" y2="68"></line>
<polyline class="slider-spring__coil" points="20 38 30.4 25.1 34.8 50.9 39.1 25.1 43.5 50.9 47.9 25.1 52.3 50.9 56.6 25.1 61.0 50.9 65.4 25.1 69.8 50.9 74.2 25.1 78.5 50.9 82.9 25.1 87.3 50.9 91.7 25.1 96.0 50.9 100.4 25.1 104.8 38 110.8 38"></polyline>
<g transform="translate(110.8 20)">
<rect class="slider-spring__weight" width="36" height="38" rx="5"></rect>
<rect class="slider-spring__weight-top" width="36" height="6" rx="3"></rect>
<text class="slider-spring__label" x="18" y="27" text-anchor="middle">5 kg</text>
</g>
</svg>
<input class="slider-spring__input" type="range" min="0" max="100" aria-label="Tension" aria-valuetext="42 newtons" value="35"/>
</div>
<span class="slider-spring__read" aria-hidden="true">Tension <b>42 N</b></span>
</div>.slider-spring { display: grid; justify-items: center; gap: 10px; width: 230px; }
.slider-spring__view { position: relative; width: 230px; height: 76px; }
.slider-spring__view svg { display: block; width: 100%; height: 100%; overflow: visible; }
.slider-spring__wall { fill: light-dark(#dbdde1, #2e3035); }
.slider-spring__hatch { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.slider-spring__coil { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
.slider-spring__floor { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1; stroke-dasharray: 3 4; }
.slider-spring__weight { fill: light-dark(#42454c, #e5e6e9); }
.slider-spring__weight-top { fill: light-dark(#373a40, #f1f2f3); }
.slider-spring__label { fill: light-dark(#ebedf0, #232529); font-size: 9px; font-weight: 600; font-family: system-ui, sans-serif; }
.slider-spring__read { color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.slider-spring__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; font-variant-numeric: tabular-nums; }
.slider-spring:has(.slider-spring__input:focus-visible) .slider-spring__weight { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2; }
.slider-spring__input { position: absolute; inset: 0 0 0 18px; width: calc(100% - 18px); height: 100%; margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-spring__input::-webkit-slider-thumb { -webkit-appearance: none; width: 36px; height: 76px; }
.slider-spring__input::-moz-range-thumb { width: 36px; height: 76px; border: 0; }function initSliderSpring(root) {
var COILS = 9;
var input = root.querySelector('.slider-spring__input');
var coil = root.querySelector('.slider-spring__coil');
var weight = root.querySelector('svg g');
var read = root.querySelector('.slider-spring__read b');
function update() {
var t = input.value / 100;
var end = 66 + t * 128; // where the spring meets the weight
var amp = 15 - t * 6;
var pts = ['20 38'];
for (var i = 1; i < COILS * 2; i++) pts.push((20 + 6 + ((end - 32) * i) / (COILS * 2)).toFixed(1) + ' ' + (i % 2 ? 38 - amp : 38 + amp));
pts.push((end - 6).toFixed(1) + ' 38', end.toFixed(1) + ' 38');
coil.setAttribute('points', pts.join(' '));
weight.setAttribute('transform', 'translate(' + end.toFixed(1) + ' 20)');
var newtons = Math.round(t * 120);
read.textContent = newtons + ' N';
input.setAttribute('aria-valuetext', newtons + ' newtons');
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-spring').forEach(initSliderSpring);Goal: Create a "Spring" slider.
Visual behavior: A tension slider as a coil spring: drag and the spring stretches between the wall and the weight, its coils flattening out as it lengthens, with the force in newtons below.
Technique:
- A real <input type="range"> covers the spring, transparent, so dragging anywhere sets the tension and the arrow, Page Up/Down, Home and End keys work natively. The drawing is aria-hidden.
- The coil is one polyline recalculated from the value: its end moves out with the weight and its zigzag amplitude shrinks, so the coils thin as it stretches.
Accessibility: A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. The scene follows the value directly, with no transitions or loops, so there is no motion you did not make.
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 "Spring" slider.
Visual behavior: A tension slider as a coil spring: drag and the spring stretches between the wall and the weight, its coils flattening out as it lengthens, with the force in newtons below.
Technique:
- A real <input type="range"> covers the spring, transparent, so dragging anywhere sets the tension and the arrow, Page Up/Down, Home and End keys work natively. The drawing is aria-hidden.
- The coil is one polyline recalculated from the value: its end moves out with the weight and its zigzag amplitude shrinks, so the coils thin as it stretches.
Accessibility: A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. The scene follows the value directly, with no transitions or loops, so there is no motion you did not make.
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 as a coil spring: drag and the spring stretches between the wall and the weight, its coils flattening out as it lengthens, with the force in newtons below.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, morph, spring
- Techniques
- svg, state machine
- 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 words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. The scene follows the value directly, with no transitions or loops, so there is no motion you did not make.