Position42.0
Drag down to fine-tuneHi-speed scrubbingHalf-speed scrubbingQuarter-speed scrubbingFine scrubbing
import { useEffect, useRef, useState } from 'react';
const W = 240;
const MID = 28;
const H = 16;
const SEGS = 22;
const css = `
.slider-twist { position: relative; width: ${W}px; height: 56px; }
.slider-twist__svg { display: block; width: ${W}px; height: 56px; overflow: visible; }
.slider-twist__ink { fill: light-dark(#42454c, #e5e6e9); }
.slider-twist__paper { fill: light-dark(#dbdde1, #2e3035); }
.slider-twist__collar {
fill: light-dark(#373a40, #f1f2f3);
filter: drop-shadow(0 1px 2px light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.5)));
transform-box: fill-box;
transform-origin: center;
transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-twist:has(.slider-twist__input:active) .slider-twist__collar { transform: scale(1.15, 1.1); }
.slider-twist__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-twist:has(.slider-twist__input:focus-visible) .slider-twist__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-twist__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-twist__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 56px; }
.slider-twist__input::-moz-range-thumb { width: 16px; height: 56px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-twist__collar { transition: none; }
}
`;
/** Follows the value on a spring and reports its speed, which stretches the twist. */
function useSpring(target: number) {
const [s, setS] = useState({ x: target, v: 0 });
const st = useRef({ x: target, v: 0, raf: 0 });
useEffect(() => {
const q = st.current;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
q.x = target;
q.v = 0;
setS({ x: target, v: 0 });
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.v += (-300 * (q.x - target) - 30 * q.v) * dt;
q.x += q.v * dt;
if (Math.abs(q.x - target) < 0.05 && Math.abs(q.v) < 1) {
q.x = target;
q.v = 0;
setS({ x: target, v: 0 });
return;
}
setS({ x: q.x, v: q.v });
q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [target]);
return s;
}
export function TwistSlider() {
const [v, setV] = useState(55);
const { x: p, v: speed } = useSpring(8 + (v / 100) * (W - 16));
// The twist stretches out behind the direction of travel.
const stretch = Math.min(46, Math.abs(speed) * 0.09);
const a = p - 14 - (speed > 0 ? stretch : 0);
const b = p + 14 + (speed < 0 ? stretch : 0);
const pinch = (a + b) / 2;
const segs = Array.from({ length: SEGS }, (_, k) => {
const clip = (x: number) => Math.min(W, Math.max(0, x));
const [x0, x1] = [clip(a + ((b - a) * k) / SEGS), clip(a + ((b - a) * (k + 1)) / SEGS)];
const [t0, t1] = [(Math.PI * k) / SEGS, (Math.PI * (k + 1)) / SEGS];
const [h0, h1] = [(H / 2) * Math.abs(Math.cos(t0)), (H / 2) * Math.abs(Math.cos(t1))];
const front = k < SEGS / 2;
// A face turned away from you catches the light differently, so shade by how far it has turned.
const turn = Math.round(Math.sin((t0 + t1) / 2) * 55);
const fill = front ? `color-mix(in oklab, light-dark(#6d7179, #7d8087) ${turn}%, light-dark(#42454c, #e5e6e9))` : `color-mix(in oklab, light-dark(#6d7179, #7d8087) ${Math.round(turn * 0.6)}%, light-dark(#dbdde1, #2e3035))`;
return (
<polygon key={k} points={`${x0},${MID - h0} ${x1},${MID - h1} ${x1},${MID + h1} ${x0},${MID + h0}`} style={{ fill, stroke: fill }} strokeWidth={0.5} strokeLinejoin="round" />
);
});
return (
<>
<style>
{css}
</style>
<div className="slider-twist">
<svg className="slider-twist__svg" aria-hidden="true">
<rect className="slider-twist__ink" x={0} y={MID - H / 2} width={Math.max(0, a + 0.5)} height={H} rx={4} />
<rect className="slider-twist__paper" x={b - 0.5} y={MID - H / 2} width={Math.max(0, W - b + 0.5)} height={H} rx={4} />
{segs}
<rect className="slider-twist__collar" x={pinch - 3} y={MID - H / 2 - 5} width={6} height={H + 10} rx={3} />
<rect className="slider-twist__ring" x={pinch - 7} y={MID - H / 2 - 9} width={14} height={H + 18} rx={7} />
</svg>
<input className="slider-twist__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Mix" />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderTwist(el) on the mounted element instead.
<div class="slider-twist">
<svg class="slider-twist__svg" aria-hidden="true">
<rect class="slider-twist__ink" x="0" y="20" width="117.7" height="16" rx="4"></rect>
<rect class="slider-twist__paper" x="144.7" y="20" width="95.3" height="16" rx="4"></rect>
<polygon points="117.2,20 118.473,20.081 118.473,35.919 117.2,36" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 4%, light-dark(#42454c, #e5e6e9));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 4%, light-dark(#42454c, #e5e6e9))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="118.473,20.081 119.745,20.324 119.745,35.676 118.473,35.919" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 12%, light-dark(#42454c, #e5e6e9));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 12%, light-dark(#42454c, #e5e6e9))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="119.745,20.324 121.018,20.723 121.018,35.277 119.745,35.676" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 19%, light-dark(#42454c, #e5e6e9));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 19%, light-dark(#42454c, #e5e6e9))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="121.018,20.723 122.291,21.27 122.291,34.73 121.018,35.277" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 26%, light-dark(#42454c, #e5e6e9));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 26%, light-dark(#42454c, #e5e6e9))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="122.291,21.27 123.564,21.954 123.564,34.046 122.291,34.73" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 33%, light-dark(#42454c, #e5e6e9));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 33%, light-dark(#42454c, #e5e6e9))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="123.564,21.954 124.836,22.761 124.836,33.239 123.564,34.046" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 39%, light-dark(#42454c, #e5e6e9));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 39%, light-dark(#42454c, #e5e6e9))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="124.836,22.761 126.109,23.675 126.109,32.325 124.836,33.239" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 44%, light-dark(#42454c, #e5e6e9));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 44%, light-dark(#42454c, #e5e6e9))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="126.109,23.675 127.382,24.677 127.382,31.323 126.109,32.325" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 48%, light-dark(#42454c, #e5e6e9));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 48%, light-dark(#42454c, #e5e6e9))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="127.382,24.677 128.655,25.746 128.655,30.254 127.382,31.323" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 52%, light-dark(#42454c, #e5e6e9));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 52%, light-dark(#42454c, #e5e6e9))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="128.655,25.746 129.927,26.861 129.927,29.139 128.655,30.254" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 54%, light-dark(#42454c, #e5e6e9));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 54%, light-dark(#42454c, #e5e6e9))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="129.927,26.861 131.2,28 131.2,28 129.927,29.139" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 55%, light-dark(#42454c, #e5e6e9));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 55%, light-dark(#42454c, #e5e6e9))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="131.2,28 132.473,26.861 132.473,29.139 131.2,28" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 33%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 33%, light-dark(#dbdde1, #2e3035))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="132.473,26.861 133.745,25.746 133.745,30.254 132.473,29.139" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 32%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 32%, light-dark(#dbdde1, #2e3035))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="133.745,25.746 135.018,24.677 135.018,31.323 133.745,30.254" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 31%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 31%, light-dark(#dbdde1, #2e3035))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="135.018,24.677 136.291,23.675 136.291,32.325 135.018,31.323" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 29%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 29%, light-dark(#dbdde1, #2e3035))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="136.291,23.675 137.564,22.761 137.564,33.239 136.291,32.325" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 26%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 26%, light-dark(#dbdde1, #2e3035))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="137.564,22.761 138.836,21.954 138.836,34.046 137.564,33.239" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 23%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 23%, light-dark(#dbdde1, #2e3035))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="138.836,21.954 140.109,21.27 140.109,34.73 138.836,34.046" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 20%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 20%, light-dark(#dbdde1, #2e3035))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="140.109,21.27 141.382,20.723 141.382,35.277 140.109,34.73" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 16%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 16%, light-dark(#dbdde1, #2e3035))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="141.382,20.723 142.655,20.324 142.655,35.676 141.382,35.277" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 11%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 11%, light-dark(#dbdde1, #2e3035))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="142.655,20.324 143.927,20.081 143.927,35.919 142.655,35.676" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 7%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 7%, light-dark(#dbdde1, #2e3035))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<polygon points="143.927,20.081 145.2,20 145.2,36 143.927,35.919" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 2%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 2%, light-dark(#dbdde1, #2e3035))" stroke-width="0.5" stroke-linejoin="round"></polygon>
<rect class="slider-twist__collar" x="128.2" y="15" width="6" height="26" rx="3"></rect>
<rect class="slider-twist__ring" x="124.2" y="11" width="14" height="34" rx="7"></rect>
</svg>
<input class="slider-twist__input" type="range" min="0" max="100" aria-label="Mix" value="55"/>
</div>.slider-twist { position: relative; width: 240px; height: 56px; }
.slider-twist__svg { display: block; width: 240px; height: 56px; overflow: visible; }
.slider-twist__ink { fill: light-dark(#42454c, #e5e6e9); }
.slider-twist__paper { fill: light-dark(#dbdde1, #2e3035); }
.slider-twist__collar {
fill: light-dark(#373a40, #f1f2f3);
filter: drop-shadow(0 1px 2px light-dark(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.5)));
transform-box: fill-box;
transform-origin: center;
transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-twist:has(.slider-twist__input:active) .slider-twist__collar { transform: scale(1.15, 1.1); }
.slider-twist__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-twist:has(.slider-twist__input:focus-visible) .slider-twist__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-twist__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-twist__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 56px; }
.slider-twist__input::-moz-range-thumb { width: 16px; height: 56px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-twist__collar { transition: none; }
}function initSliderTwist(root) {
var W = 240;
var MID = 28;
var H = 16;
var SEGS = 22;
var INK = 'light-dark(#42454c, #e5e6e9)';
var PAPER = 'light-dark(#dbdde1, #2e3035)';
var SHADE = 'light-dark(#6d7179, #7d8087)';
var input = root.querySelector('.slider-twist__input');
var ink = root.querySelector('.slider-twist__ink');
var paper = root.querySelector('.slider-twist__paper');
var faces = root.querySelectorAll('polygon');
var collar = root.querySelector('.slider-twist__collar');
var ring = root.querySelector('.slider-twist__ring');
var p = target();
var speed = 0;
var raf = 0;
function target() { return 8 + (+input.value / 100) * (W - 16); }
function clip(x) { return Math.min(W, Math.max(0, x)); }
function draw() {
// The twist stretches out behind the direction of travel.
var stretch = Math.min(46, Math.abs(speed) * 0.09);
var a = p - 14 - (speed > 0 ? stretch : 0);
var b = p + 14 + (speed < 0 ? stretch : 0);
var pinch = (a + b) / 2;
ink.setAttribute('width', Math.max(0, a + 0.5));
paper.setAttribute('x', b - 0.5);
paper.setAttribute('width', Math.max(0, W - b + 0.5));
faces.forEach(function (face, k) {
var x0 = clip(a + ((b - a) * k) / SEGS);
var x1 = clip(a + ((b - a) * (k + 1)) / SEGS);
var t0 = (Math.PI * k) / SEGS;
var t1 = (Math.PI * (k + 1)) / SEGS;
var h0 = (H / 2) * Math.abs(Math.cos(t0));
var h1 = (H / 2) * Math.abs(Math.cos(t1));
// A face turned away from you catches the light differently, so shade by how far it has turned.
var turn = Math.round(Math.sin((t0 + t1) / 2) * 55);
var fill = k < SEGS / 2
? 'color-mix(in oklab, ' + SHADE + ' ' + turn + '%, ' + INK + ')'
: 'color-mix(in oklab, ' + SHADE + ' ' + Math.round(turn * 0.6) + '%, ' + PAPER + ')';
face.setAttribute('points', x0 + ',' + (MID - h0) + ' ' + x1 + ',' + (MID - h1) + ' ' + x1 + ',' + (MID + h1) + ' ' + x0 + ',' + (MID + h0));
face.style.fill = fill;
face.style.stroke = fill;
});
collar.setAttribute('x', pinch - 3);
ring.setAttribute('x', pinch - 7);
}
// Follows the value on a spring, whose speed stretches the twist.
function run() {
cancelAnimationFrame(raf);
var goal = target();
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
p = goal;
speed = 0;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
speed += (-300 * (p - goal) - 30 * speed) * dt;
p += speed * dt;
if (Math.abs(p - goal) < 0.05 && Math.abs(speed) < 1) {
p = goal;
speed = 0;
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('input', run);
draw();
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', run);
};
}
document.querySelectorAll('.slider-twist').forEach(initSliderTwist);Goal: Create a "Twist" slider.
Visual behavior: A slider whose track is a ribbon with a half twist at the thumb: ink on one face, paper on the other. Drag fast and the twist stretches out behind you, then tightens as you stop.
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.
- Either side of the twist is a flat rectangle, ink behind and paper ahead. The twist between them is a row of SVG quads whose half-heights follow |cos| of an angle running from 0 to 180 degrees, so the ribbon pinches to a line and opens again; each quad is shaded by the sine of its angle, as a face turning away catches the light.
- The drawn position follows the input on a spring, and the spring's speed lengthens the twist on the side it is coming from. A small collar marks the pinch.
Accessibility: A native range input with an aria-label; keyboard focus rings the collar. Under prefers-reduced-motion the twist moves without the spring and never stretches.
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 "Twist" slider.
Visual behavior: A slider whose track is a ribbon with a half twist at the thumb: ink on one face, paper on the other. Drag fast and the twist stretches out behind you, then tightens as you stop.
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.
- Either side of the twist is a flat rectangle, ink behind and paper ahead. The twist between them is a row of SVG quads whose half-heights follow |cos| of an angle running from 0 to 180 degrees, so the ribbon pinches to a line and opens again; each quad is shaded by the sine of its angle, as a face turning away catches the light.
- The drawn position follows the input on a spring, and the spring's speed lengthens the twist on the side it is coming from. A small collar marks the pinch.
Accessibility: A native range input with an aria-label; keyboard focus rings the collar. Under prefers-reduced-motion the twist moves without the spring and never stretches.
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
A slider whose track is a ribbon with a half twist at the thumb: ink on one face, paper on the other. Drag fast and the twist stretches out behind you, then tightens as you stop.
A native range input with an aria-label; keyboard focus rings the collar. Under prefers-reduced-motion the twist moves without the spring and never stretches.