Pleat
import { useEffect, useRef, useState } from 'react';
const W = 240;
const MID = 28;
const H = 9;
const FOLDS = 10;
const FACE = 26;
const css = `
.slider-pleat { position: relative; width: ${W}px; height: 56px; }
.slider-pleat__svg { display: block; width: ${W}px; height: 56px; overflow: visible; }
.slider-pleat__flat { fill: light-dark(#42454c, #e5e6e9); }
.slider-pleat__tab {
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)));
transform-box: fill-box;
transform-origin: center;
transition: transform 0.35s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-pleat:has(.slider-pleat__input:active) .slider-pleat__tab { transform: scale(1.12, 1.08); }
.slider-pleat__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-pleat:has(.slider-pleat__input:focus-visible) .slider-pleat__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-pleat__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-pleat__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 56px; }
.slider-pleat__input::-moz-range-thumb { width: 16px; height: 56px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-pleat__tab { transition: none; }
}
`;
/** Follows the value on a soft spring, so a click far along the track folds the ribbon up rather than jumping. */
function useSpring(target: number) {
const [x, setX] = useState(target);
const s = useRef({ x: target, v: 0, raf: 0 });
useEffect(() => {
const st = s.current;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
st.x = target;
setX(target);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
st.v += (-260 * (st.x - target) - 24 * st.v) * dt;
st.x += st.v * dt;
if (Math.abs(st.x - target) < 0.02 && Math.abs(st.v) < 0.05) {
st.x = target;
st.v = 0;
setX(target);
return;
}
setX(st.x);
st.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(st.raf);
st.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(st.raf);
}, [target]);
return x;
}
export function PleatSlider() {
const [v, setV] = useState(30);
const shown = useSpring(v);
const t = 8 + (shown / 100) * (W - 16);
const w = (W - t) / FOLDS;
// How tightly packed the folds are: 0 lies flat, 1 is a closed stack.
const c = Math.sqrt(Math.max(0, 1 - (w / FACE) ** 2));
// Ridges stand proud of the ribbon, valleys sink into it; both deepen as the folds pack.
const half = (k: number) => (k % 2 ? H + 4 * c : H - c);
const faces = Array.from({ length: FOLDS }, (_, k) => {
const [x0, x1, h0, h1] = [t + k * w, t + (k + 1) * w, half(k), half(k + 1)];
// Faces turned to the light and away from it, never so dark they drop into the page.
const fill =
k % 2
? `color-mix(in oklab, light-dark(#ebedf0, #232529) ${Math.round(40 + c * 60)}%, light-dark(#dbdde1, #2e3035))`
: `color-mix(in oklab, light-dark(#6d7179, #7d8087) ${Math.round(14 + c * 40)}%, 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.6}
strokeLinejoin="round"
/>
);
});
return (
<>
<style>
{css}
</style>
<div className="slider-pleat">
<svg className="slider-pleat__svg" aria-hidden="true">
<path className="slider-pleat__flat" d={`M5 ${MID - H}H${t}V${MID + H}H5a5 5 0 0 1-5-5V${MID - H + 5} a5 5 0 0 1 5-5Z`} />
{faces}
<rect className="slider-pleat__tab" x={t - 4} y={MID - 16} width={8} height={32} rx={4} />
<rect className="slider-pleat__ring" x={t - 8} y={MID - 20} width={16} height={40} rx={8} />
</svg>
<input
className="slider-pleat__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Length"
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderPleat(el) on the mounted element instead.
<div class="slider-pleat">
<svg class="slider-pleat__svg" aria-hidden="true">
<path class="slider-pleat__flat" d="M5 19H75.2V37H5a5 5 0 0 1-5-5V24 a5 5 0 0 1 5-5Z"></path>
<polygon points="75.2,19.773 91.68,15.906 91.68,40.094 75.2,36.227" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 45%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 45%, light-dark(#dbdde1, #2e3035))" stroke-width="0.6" stroke-linejoin="round"></polygon>
<polygon points="91.68,15.906 108.16,19.773 108.16,36.227 91.68,40.094" style="fill:color-mix(in oklab, light-dark(#ebedf0, #232529) 86%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#ebedf0, #232529) 86%, light-dark(#dbdde1, #2e3035))" stroke-width="0.6" stroke-linejoin="round"></polygon>
<polygon points="108.16,19.773 124.64,15.906 124.64,40.094 108.16,36.227" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 45%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 45%, light-dark(#dbdde1, #2e3035))" stroke-width="0.6" stroke-linejoin="round"></polygon>
<polygon points="124.64,15.906 141.12,19.773 141.12,36.227 124.64,40.094" style="fill:color-mix(in oklab, light-dark(#ebedf0, #232529) 86%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#ebedf0, #232529) 86%, light-dark(#dbdde1, #2e3035))" stroke-width="0.6" stroke-linejoin="round"></polygon>
<polygon points="141.12,19.773 157.6,15.906 157.6,40.094 141.12,36.227" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 45%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 45%, light-dark(#dbdde1, #2e3035))" stroke-width="0.6" stroke-linejoin="round"></polygon>
<polygon points="157.6,15.906 174.08,19.773 174.08,36.227 157.6,40.094" style="fill:color-mix(in oklab, light-dark(#ebedf0, #232529) 86%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#ebedf0, #232529) 86%, light-dark(#dbdde1, #2e3035))" stroke-width="0.6" stroke-linejoin="round"></polygon>
<polygon points="174.08,19.773 190.56,15.906 190.56,40.094 174.08,36.227" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 45%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 45%, light-dark(#dbdde1, #2e3035))" stroke-width="0.6" stroke-linejoin="round"></polygon>
<polygon points="190.56,15.906 207.04,19.773 207.04,36.227 190.56,40.094" style="fill:color-mix(in oklab, light-dark(#ebedf0, #232529) 86%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#ebedf0, #232529) 86%, light-dark(#dbdde1, #2e3035))" stroke-width="0.6" stroke-linejoin="round"></polygon>
<polygon points="207.04,19.773 223.52,15.906 223.52,40.094 207.04,36.227" style="fill:color-mix(in oklab, light-dark(#6d7179, #7d8087) 45%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#6d7179, #7d8087) 45%, light-dark(#dbdde1, #2e3035))" stroke-width="0.6" stroke-linejoin="round"></polygon>
<polygon points="223.52,15.906 240,19.773 240,36.227 223.52,40.094" style="fill:color-mix(in oklab, light-dark(#ebedf0, #232529) 86%, light-dark(#dbdde1, #2e3035));stroke:color-mix(in oklab, light-dark(#ebedf0, #232529) 86%, light-dark(#dbdde1, #2e3035))" stroke-width="0.6" stroke-linejoin="round"></polygon>
<rect class="slider-pleat__tab" x="71.2" y="12" width="8" height="32" rx="4"></rect>
<rect class="slider-pleat__ring" x="67.2" y="8" width="16" height="40" rx="8"></rect>
</svg>
<input class="slider-pleat__input" type="range" min="0" max="100" aria-label="Length" value="30"/>
</div>.slider-pleat { position: relative; width: 240px; height: 56px; }
.slider-pleat__svg { display: block; width: 240px; height: 56px; overflow: visible; }
.slider-pleat__flat { fill: light-dark(#42454c, #e5e6e9); }
.slider-pleat__tab {
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)));
transform-box: fill-box;
transform-origin: center;
transition: transform 0.35s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-pleat:has(.slider-pleat__input:active) .slider-pleat__tab { transform: scale(1.12, 1.08); }
.slider-pleat__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-pleat:has(.slider-pleat__input:focus-visible) .slider-pleat__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-pleat__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-pleat__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 56px; }
.slider-pleat__input::-moz-range-thumb { width: 16px; height: 56px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-pleat__tab { transition: none; }
}function initSliderPleat(root) {
var W = 240, MID = 28, H = 9, FOLDS = 10, FACE = 26;
var input = root.querySelector('.slider-pleat__input');
var flat = root.querySelector('.slider-pleat__flat');
var faces = root.querySelectorAll('polygon');
var tab = root.querySelector('.slider-pleat__tab');
var ring = root.querySelector('.slider-pleat__ring');
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var s = { x: +input.value, v: 0, raf: 0 };
function draw(shown) {
var t = 8 + (shown / 100) * (W - 16);
var w = (W - t) / FOLDS;
// How tightly packed the folds are: 0 lies flat, 1 is a closed stack.
var c = Math.sqrt(Math.max(0, 1 - Math.pow(w / FACE, 2)));
// Ridges stand proud of the ribbon, valleys sink into it; both deepen as the folds pack.
function half(k) { return k % 2 ? H + 4 * c : H - c; }
faces.forEach(function (face, k) {
var x0 = t + k * w, x1 = t + (k + 1) * w, h0 = half(k), h1 = half(k + 1);
face.setAttribute('points', x0 + ',' + (MID - h0) + ' ' + x1 + ',' + (MID - h1) + ' ' + x1 + ',' + (MID + h1) + ' ' + x0 + ',' + (MID + h0));
// Faces turned to the light and away from it, never so dark they drop into the page.
var fill = k % 2
? 'color-mix(in oklab, light-dark(#ebedf0, #232529) ' + Math.round(40 + c * 60) + '%, light-dark(#dbdde1, #2e3035))'
: 'color-mix(in oklab, light-dark(#6d7179, #7d8087) ' + Math.round(14 + c * 40) + '%, light-dark(#dbdde1, #2e3035))';
face.style.fill = fill;
face.style.stroke = fill;
});
flat.setAttribute('d', 'M5 ' + (MID - H) + 'H' + t + 'V' + (MID + H) + 'H5a5 5 0 0 1-5-5V' + (MID - H + 5) + 'a5 5 0 0 1 5-5Z');
tab.setAttribute('x', t - 4);
ring.setAttribute('x', t - 8);
}
// Follows the value on a soft spring, so a click far along the track folds the ribbon up rather than jumping.
function update() {
var target = +input.value;
cancelAnimationFrame(s.raf);
if (still) {
s.x = target;
draw(target);
return;
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
s.v += (-260 * (s.x - target) - 24 * s.v) * dt;
s.x += s.v * dt;
if (Math.abs(s.x - target) < 0.02 && Math.abs(s.v) < 0.05) {
s.x = target;
s.v = 0;
draw(target);
return;
}
draw(s.x);
s.raf = requestAnimationFrame(step);
}
s.raf = requestAnimationFrame(step);
}
input.addEventListener('input', update);
draw(s.x);
return function () {
cancelAnimationFrame(s.raf);
input.removeEventListener('input', update);
};
}
document.querySelectorAll('.slider-pleat').forEach(initSliderPleat);Goal: Create a "Pleat" slider.
Visual behavior: A slider whose ribbon folds like an accordion: the part ahead of the thumb is pleated and packs tighter as you push, then pulls flat again as you drag back.
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.
- Ahead of the thumb, ten SVG quads share the remaining width. Against a fixed paper width per face, the projected width gives how steep each fold is, which sets how far the ridges stand out and how strongly the lit and shaded faces differ.
- The drawn value follows the input on a soft spring, so a click far along the track folds the ribbon up rather than jumping.
Accessibility: A native range input with an aria-label; keyboard focus rings the pull tab. Under prefers-reduced-motion the ribbon folds without the spring.
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 "Pleat" slider.
Visual behavior: A slider whose ribbon folds like an accordion: the part ahead of the thumb is pleated and packs tighter as you push, then pulls flat again as you drag back.
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.
- Ahead of the thumb, ten SVG quads share the remaining width. Against a fixed paper width per face, the projected width gives how steep each fold is, which sets how far the ridges stand out and how strongly the lit and shaded faces differ.
- The drawn value follows the input on a soft spring, so a click far along the track folds the ribbon up rather than jumping.
Accessibility: A native range input with an aria-label; keyboard focus rings the pull tab. Under prefers-reduced-motion the ribbon folds without the spring.
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 whose ribbon folds like an accordion: the part ahead of the thumb is pleated and packs tighter as you push, then pulls flat again as you drag back.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, continuous, 3d, spring
- 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 pull tab. Under prefers-reduced-motion the ribbon folds without the spring.