Strand
import { useEffect, useRef, useState } from 'react';
const W = 240;
const MID = 22;
const N = 5;
const BOUND = 1.75;
const LOOSE = 3.9;
const smooth = (a: number, b: number, x: number) => {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
};
const css = `
.slider-strand { position: relative; width: ${W}px; height: 62px; }
.slider-strand__svg { display: block; width: ${W}px; height: 44px; overflow: visible; }
.slider-strand__bound { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 1.1; stroke-linecap: round; }
.slider-strand__loose { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; stroke-linecap: round; }
.slider-strand__ring {
fill: light-dark(#373a40, #f1f2f3);
filter: drop-shadow(0 1px 2px light-dark(rgba(0, 0, 0, 0.22), rgba(0, 0, 0, 0.55)));
transform-box: fill-box;
transform-origin: center;
transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-strand:has(.slider-strand__input:active) .slider-strand__ring { transform: scale(1.12, 1.08); }
.slider-strand__focus { fill: none; stroke: transparent; stroke-width: 2; }
.slider-strand:has(.slider-strand__input:focus-visible) .slider-strand__focus { stroke: light-dark(#50535b, #c6c8cd); }
.slider-strand__value { position: absolute; left: 0; right: 0; top: 44px; display: flex; justify-content: space-between; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-strand__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-strand__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-strand__input::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 44px; }
.slider-strand__input::-moz-range-thumb { width: 14px; height: 44px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-strand__ring { transition: none; }
}
`;
/**
* The ring follows the value on a spring. Moving it sets the slack strands rippling away from it;
* the ripple dies down after it stops.
*/
function useStrands(target: number) {
const [s, setS] = useState({ t: target, amp: 0, phase: 0 });
const st = useRef({ t: target, v: 0, amp: 0, phase: 0, raf: 0 });
useEffect(() => {
const q = st.current;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
Object.assign(q, { t: target, v: 0, amp: 0 });
setS({ t: target, amp: 0, phase: q.phase });
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.v += (-240 * (q.t - target) - 28 * q.v) * dt;
q.t += q.v * dt;
q.amp = Math.max(q.amp * Math.exp(-dt * 2.6), Math.min(3.4, Math.abs(q.v) * 0.012));
q.phase += dt * 9;
if (Math.abs(q.t - target) < 0.05 && Math.abs(q.v) < 0.5 && q.amp < 0.03) {
Object.assign(q, { t: target, v: 0, amp: 0 });
setS({ t: target, amp: 0, phase: q.phase });
return;
}
setS({ t: q.t, amp: q.amp, phase: q.phase });
q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [target]);
return s;
}
export function StrandSlider() {
const [v, setV] = useState(48);
const { t, amp, phase } = useStrands(7 + (v / 100) * (W - 14));
const strands = Array.from({ length: N }, (_, i) => {
const o = i - (N - 1) / 2;
const y = (x: number) => {
// Bound behind the ring, fanning out to loose over the next 40px, held again at the far end.
const spread = BOUND + (LOOSE - BOUND) * smooth(t, t + 40, x) * (1 - smooth(W - 26, W, x) * 0.55);
const env = x > t ? Math.sin((Math.PI * (x - t)) / Math.max(1, W - t)) : 0;
return MID + o * spread + amp * env * Math.sin((x - t) / 11 - phase + i * 0.9);
};
const pts = (a: number, b: number) => {
const out: string[] = [];
for (let x = a; x < b; x += 3) out.push(`${x.toFixed(1)},${y(x).toFixed(2)}`);
out.push(`${b.toFixed(1)},${y(b).toFixed(2)}`);
return `M${out.join('L')}`;
};
return { behind: pts(0.5, t), ahead: pts(t, W - 0.5) };
});
return (
<>
<style>
{css}
</style>
<div className="slider-strand">
<svg className="slider-strand__svg" aria-hidden="true">
{strands.map((s, i) => (
<path key={`a${i}`} className="slider-strand__loose" d={s.ahead} />
))}
{strands.map((s, i) => (
<path key={`b${i}`} className="slider-strand__bound" d={s.behind} />
))}
<rect className="slider-strand__ring" x={t - 4} y={MID - 10} width={8} height={20} rx={4} />
<rect className="slider-strand__focus" x={t - 8} y={MID - 14} width={16} height={28} rx={8} />
</svg>
<p className="slider-strand__value" aria-hidden="true">
<span>Strength</span>
<b>{v}%</b>
</p>
<input className="slider-strand__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Strength" aria-valuetext={`${v}%`} />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderStrand(el) on the mounted element instead.
<div class="slider-strand">
<svg class="slider-strand__svg" aria-hidden="true">
<path class="slider-strand__loose" d="M115.5,18.50L118.5,18.43L121.5,18.24L124.5,17.94L127.5,17.57L130.5,17.14L133.5,16.67L136.5,16.19L139.5,15.71L142.5,15.27L145.5,14.87L148.5,14.55L151.5,14.32L154.5,14.21L157.5,14.20L160.5,14.20L163.5,14.20L166.5,14.20L169.5,14.20L172.5,14.20L175.5,14.20L178.5,14.20L181.5,14.20L184.5,14.20L187.5,14.20L190.5,14.20L193.5,14.20L196.5,14.20L199.5,14.20L202.5,14.20L205.5,14.20L208.5,14.20L211.5,14.20L214.5,14.20L217.5,14.32L220.5,14.57L223.5,14.91L226.5,15.31L229.5,15.72L232.5,16.09L235.5,16.38L238.5,16.54L239.5,16.56"></path>
<path class="slider-strand__loose" d="M115.5,20.25L118.5,20.22L121.5,20.12L124.5,19.97L127.5,19.79L130.5,19.57L133.5,19.34L136.5,19.09L139.5,18.86L142.5,18.63L145.5,18.44L148.5,18.27L151.5,18.16L154.5,18.10L157.5,18.10L160.5,18.10L163.5,18.10L166.5,18.10L169.5,18.10L172.5,18.10L175.5,18.10L178.5,18.10L181.5,18.10L184.5,18.10L187.5,18.10L190.5,18.10L193.5,18.10L196.5,18.10L199.5,18.10L202.5,18.10L205.5,18.10L208.5,18.10L211.5,18.10L214.5,18.10L217.5,18.16L220.5,18.28L223.5,18.46L226.5,18.66L229.5,18.86L232.5,19.04L235.5,19.19L238.5,19.27L239.5,19.28"></path>
<path class="slider-strand__loose" d="M115.5,22.00L118.5,22.00L121.5,22.00L124.5,22.00L127.5,22.00L130.5,22.00L133.5,22.00L136.5,22.00L139.5,22.00L142.5,22.00L145.5,22.00L148.5,22.00L151.5,22.00L154.5,22.00L157.5,22.00L160.5,22.00L163.5,22.00L166.5,22.00L169.5,22.00L172.5,22.00L175.5,22.00L178.5,22.00L181.5,22.00L184.5,22.00L187.5,22.00L190.5,22.00L193.5,22.00L196.5,22.00L199.5,22.00L202.5,22.00L205.5,22.00L208.5,22.00L211.5,22.00L214.5,22.00L217.5,22.00L220.5,22.00L223.5,22.00L226.5,22.00L229.5,22.00L232.5,22.00L235.5,22.00L238.5,22.00L239.5,22.00"></path>
<path class="slider-strand__loose" d="M115.5,23.75L118.5,23.78L121.5,23.88L124.5,24.03L127.5,24.21L130.5,24.43L133.5,24.66L136.5,24.91L139.5,25.14L142.5,25.37L145.5,25.56L148.5,25.73L151.5,25.84L154.5,25.90L157.5,25.90L160.5,25.90L163.5,25.90L166.5,25.90L169.5,25.90L172.5,25.90L175.5,25.90L178.5,25.90L181.5,25.90L184.5,25.90L187.5,25.90L190.5,25.90L193.5,25.90L196.5,25.90L199.5,25.90L202.5,25.90L205.5,25.90L208.5,25.90L211.5,25.90L214.5,25.90L217.5,25.84L220.5,25.72L223.5,25.54L226.5,25.34L229.5,25.14L232.5,24.96L235.5,24.81L238.5,24.73L239.5,24.72"></path>
<path class="slider-strand__loose" d="M115.5,25.50L118.5,25.57L121.5,25.76L124.5,26.06L127.5,26.43L130.5,26.86L133.5,27.33L136.5,27.81L139.5,28.29L142.5,28.73L145.5,29.13L148.5,29.45L151.5,29.68L154.5,29.79L157.5,29.80L160.5,29.80L163.5,29.80L166.5,29.80L169.5,29.80L172.5,29.80L175.5,29.80L178.5,29.80L181.5,29.80L184.5,29.80L187.5,29.80L190.5,29.80L193.5,29.80L196.5,29.80L199.5,29.80L202.5,29.80L205.5,29.80L208.5,29.80L211.5,29.80L214.5,29.80L217.5,29.68L220.5,29.43L223.5,29.09L226.5,28.69L229.5,28.28L232.5,27.91L235.5,27.62L238.5,27.46L239.5,27.44"></path>
<path class="slider-strand__bound" d="M0.5,18.50L3.5,18.50L6.5,18.50L9.5,18.50L12.5,18.50L15.5,18.50L18.5,18.50L21.5,18.50L24.5,18.50L27.5,18.50L30.5,18.50L33.5,18.50L36.5,18.50L39.5,18.50L42.5,18.50L45.5,18.50L48.5,18.50L51.5,18.50L54.5,18.50L57.5,18.50L60.5,18.50L63.5,18.50L66.5,18.50L69.5,18.50L72.5,18.50L75.5,18.50L78.5,18.50L81.5,18.50L84.5,18.50L87.5,18.50L90.5,18.50L93.5,18.50L96.5,18.50L99.5,18.50L102.5,18.50L105.5,18.50L108.5,18.50L111.5,18.50L114.5,18.50L115.5,18.50"></path>
<path class="slider-strand__bound" d="M0.5,20.25L3.5,20.25L6.5,20.25L9.5,20.25L12.5,20.25L15.5,20.25L18.5,20.25L21.5,20.25L24.5,20.25L27.5,20.25L30.5,20.25L33.5,20.25L36.5,20.25L39.5,20.25L42.5,20.25L45.5,20.25L48.5,20.25L51.5,20.25L54.5,20.25L57.5,20.25L60.5,20.25L63.5,20.25L66.5,20.25L69.5,20.25L72.5,20.25L75.5,20.25L78.5,20.25L81.5,20.25L84.5,20.25L87.5,20.25L90.5,20.25L93.5,20.25L96.5,20.25L99.5,20.25L102.5,20.25L105.5,20.25L108.5,20.25L111.5,20.25L114.5,20.25L115.5,20.25"></path>
<path class="slider-strand__bound" d="M0.5,22.00L3.5,22.00L6.5,22.00L9.5,22.00L12.5,22.00L15.5,22.00L18.5,22.00L21.5,22.00L24.5,22.00L27.5,22.00L30.5,22.00L33.5,22.00L36.5,22.00L39.5,22.00L42.5,22.00L45.5,22.00L48.5,22.00L51.5,22.00L54.5,22.00L57.5,22.00L60.5,22.00L63.5,22.00L66.5,22.00L69.5,22.00L72.5,22.00L75.5,22.00L78.5,22.00L81.5,22.00L84.5,22.00L87.5,22.00L90.5,22.00L93.5,22.00L96.5,22.00L99.5,22.00L102.5,22.00L105.5,22.00L108.5,22.00L111.5,22.00L114.5,22.00L115.5,22.00"></path>
<path class="slider-strand__bound" d="M0.5,23.75L3.5,23.75L6.5,23.75L9.5,23.75L12.5,23.75L15.5,23.75L18.5,23.75L21.5,23.75L24.5,23.75L27.5,23.75L30.5,23.75L33.5,23.75L36.5,23.75L39.5,23.75L42.5,23.75L45.5,23.75L48.5,23.75L51.5,23.75L54.5,23.75L57.5,23.75L60.5,23.75L63.5,23.75L66.5,23.75L69.5,23.75L72.5,23.75L75.5,23.75L78.5,23.75L81.5,23.75L84.5,23.75L87.5,23.75L90.5,23.75L93.5,23.75L96.5,23.75L99.5,23.75L102.5,23.75L105.5,23.75L108.5,23.75L111.5,23.75L114.5,23.75L115.5,23.75"></path>
<path class="slider-strand__bound" d="M0.5,25.50L3.5,25.50L6.5,25.50L9.5,25.50L12.5,25.50L15.5,25.50L18.5,25.50L21.5,25.50L24.5,25.50L27.5,25.50L30.5,25.50L33.5,25.50L36.5,25.50L39.5,25.50L42.5,25.50L45.5,25.50L48.5,25.50L51.5,25.50L54.5,25.50L57.5,25.50L60.5,25.50L63.5,25.50L66.5,25.50L69.5,25.50L72.5,25.50L75.5,25.50L78.5,25.50L81.5,25.50L84.5,25.50L87.5,25.50L90.5,25.50L93.5,25.50L96.5,25.50L99.5,25.50L102.5,25.50L105.5,25.50L108.5,25.50L111.5,25.50L114.5,25.50L115.5,25.50"></path>
<rect class="slider-strand__ring" x="111.48" y="12" width="8" height="20" rx="4"></rect>
<rect class="slider-strand__focus" x="107.48" y="8" width="16" height="28" rx="8"></rect>
</svg>
<p class="slider-strand__value" aria-hidden="true">
<span>Strength</span>
<b>48%</b>
</p>
<input class="slider-strand__input" type="range" min="0" max="100" aria-label="Strength" aria-valuetext="48%" value="48"/>
</div>.slider-strand { position: relative; width: 240px; height: 62px; }
.slider-strand__svg { display: block; width: 240px; height: 44px; overflow: visible; }
.slider-strand__bound { fill: none; stroke: light-dark(#42454c, #e5e6e9); stroke-width: 1.1; stroke-linecap: round; }
.slider-strand__loose { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; stroke-linecap: round; }
.slider-strand__ring {
fill: light-dark(#373a40, #f1f2f3);
filter: drop-shadow(0 1px 2px light-dark(rgba(0, 0, 0, 0.22), rgba(0, 0, 0, 0.55)));
transform-box: fill-box;
transform-origin: center;
transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-strand:has(.slider-strand__input:active) .slider-strand__ring { transform: scale(1.12, 1.08); }
.slider-strand__focus { fill: none; stroke: transparent; stroke-width: 2; }
.slider-strand:has(.slider-strand__input:focus-visible) .slider-strand__focus { stroke: light-dark(#50535b, #c6c8cd); }
.slider-strand__value { position: absolute; left: 0; right: 0; top: 44px; display: flex; justify-content: space-between; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-strand__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-strand__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-strand__input::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 44px; }
.slider-strand__input::-moz-range-thumb { width: 14px; height: 44px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-strand__ring { transition: none; }
}function initSliderStrand(root) {
var W = 240;
var MID = 22;
var BOUND = 1.75;
var LOOSE = 3.9;
var input = root.querySelector('.slider-strand__input');
var loose = root.querySelectorAll('.slider-strand__loose');
var bound = root.querySelectorAll('.slider-strand__bound');
var ring = root.querySelector('.slider-strand__ring');
var focus = root.querySelector('.slider-strand__focus');
var label = root.querySelector('.slider-strand__value b');
var N = loose.length;
var q = { t: target(), v: 0, amp: 0, phase: 0 };
var raf = 0;
function target() { return 7 + (+input.value / 100) * (W - 14); }
function smooth(a, b, x) {
var t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
}
function draw() {
var t = q.t;
for (var i = 0; i < N; i++) {
var o = i - (N - 1) / 2;
var y = function (x) {
// Bound behind the ring, fanning out to loose over the next 40px, held again at the far end.
var spread = BOUND + (LOOSE - BOUND) * smooth(t, t + 40, x) * (1 - smooth(W - 26, W, x) * 0.55);
var env = x > t ? Math.sin((Math.PI * (x - t)) / Math.max(1, W - t)) : 0;
return MID + o * spread + q.amp * env * Math.sin((x - t) / 11 - q.phase + i * 0.9);
};
var pts = function (a, b) {
var out = [];
for (var x = a; x < b; x += 3) out.push(x.toFixed(1) + ',' + y(x).toFixed(2));
out.push(b.toFixed(1) + ',' + y(b).toFixed(2));
return 'M' + out.join('L');
};
loose[i].setAttribute('d', pts(t, W - 0.5));
bound[i].setAttribute('d', pts(0.5, t));
}
ring.setAttribute('x', t - 4);
focus.setAttribute('x', t - 8);
}
// Moving the ring sets the slack strands rippling away from it; the ripple dies down after it stops.
function run() {
cancelAnimationFrame(raf);
var goal = target();
label.textContent = input.value + '%';
input.setAttribute('aria-valuetext', input.value + '%');
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
q.t = goal;
q.v = 0;
q.amp = 0;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.v += (-240 * (q.t - goal) - 28 * q.v) * dt;
q.t += q.v * dt;
q.amp = Math.max(q.amp * Math.exp(-dt * 2.6), Math.min(3.4, Math.abs(q.v) * 0.012));
q.phase += dt * 9;
if (Math.abs(q.t - goal) < 0.05 && Math.abs(q.v) < 0.5 && q.amp < 0.03) {
q.t = goal;
q.v = 0;
q.amp = 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-strand').forEach(initSliderStrand);Goal: Create a "Strand" slider.
Visual behavior: A slider made of five strands drawn through a ring: behind the ring they lie bound together as the fill, and ahead they fan out loose, rippling away from the ring as you drag.
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.
- Each strand is two SVG paths, bound behind the ring and loose ahead, sampled every 3px. Their spread eases from tight to loose over 40px past the ring and draws in again at the far end, where they are held.
- The ring follows the input on a spring. Its speed sets the amplitude of a sine wave along the loose strands, under an envelope that is zero at the ring and at the far end, with a phase that advances so the ripple travels away; the amplitude decays after it stops.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the ring. Under prefers-reduced-motion the strands never ripple.
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 "Strand" slider.
Visual behavior: A slider made of five strands drawn through a ring: behind the ring they lie bound together as the fill, and ahead they fan out loose, rippling away from the ring as you drag.
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.
- Each strand is two SVG paths, bound behind the ring and loose ahead, sampled every 3px. Their spread eases from tight to loose over 40px past the ring and draws in again at the far end, where they are held.
- The ring follows the input on a spring. Its speed sets the amplitude of a sine wave along the loose strands, under an envelope that is zero at the ring and at the far end, with a phase that advances so the ripple travels away; the amplitude decays after it stops.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the ring. Under prefers-reduced-motion the strands never ripple.
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 made of five strands drawn through a ring: behind the ring they lie bound together as the fill, and ahead they fan out loose, rippling away from the ring as you drag.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, spring, continuous
- 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 and an aria-valuetext in percent; keyboard focus rings the ring. Under prefers-reduced-motion the strands never ripple.