Zipper
import { useState } from 'react';
const W = 220;
const MID = 34;
const OPEN = 20; // How far each edge has parted at the far end when fully unzipped.
const css = `
.slider-zipper { position: relative; width: 220px; height: 68px; }
.slider-zipper svg { display: block; width: 220px; height: 68px; overflow: visible; }
.slider-zipper__inside { fill: light-dark(#fbfcfd, #121417); }
.slider-zipper__fabric { fill: light-dark(#dbdde1, #2e3035); }
.slider-zipper__tooth { fill: light-dark(#50535b, #c6c8cd); }
.slider-zipper__slider { fill: light-dark(#42454c, #e5e6e9); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.slider-zipper__pull { fill: light-dark(#373a40, #f1f2f3); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.slider-zipper__hole { fill: light-dark(#dbdde1, #2e3035); }
.slider-zipper:has(.slider-zipper__input:focus-visible) .slider-zipper__slider { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2; }
.slider-zipper__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-zipper__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 68px; }
.slider-zipper__input::-moz-range-thumb { width: 20px; height: 68px; border: 0; }
`;
export function ZipperSlider() {
const [v, setV] = useState(55);
const px = 10 + (v / 100) * (W - 20);
const spread = (OPEN * px) / W;
// How far an edge has parted at x: nothing at the pull, most at the left end.
const off = (x: number) => (x >= px ? 0 : (spread * (px - x)) / px);
const teeth = [];
for (let x = 2; x < W; x += 10) {
teeth.push(<rect key={`t${x}`} className="slider-zipper__tooth" x={x} y={(MID - 7 - off(x + 3)).toFixed(1)} width="6" height="7" rx="1.5" />);
teeth.push(<rect key={`b${x}`} className="slider-zipper__tooth" x={x + 5} y={(MID + off(x + 8)).toFixed(1)} width="6" height="7" rx="1.5" />);
}
const top = (MID - 5 - spread).toFixed(1);
const bottom = (MID + 5 + spread).toFixed(1);
return (
<>
<style>
{css}
</style>
<div className="slider-zipper">
<svg viewBox={`0 0 ${W} 68`} aria-hidden="true">
<rect className="slider-zipper__inside" x="0" y="0" width={W} height="68" rx="8" />
<path className="slider-zipper__fabric" d={`M0 8a8 8 0 0 1 8-8H${W - 8} a8 8 0 0 1 8 8V${MID - 5}H${px.toFixed(1)}L0 ${top}Z`} />
<path className="slider-zipper__fabric" d={`M0 60a8 8 0 0 0 8 8H${W - 8} a8 8 0 0 0 8-8V${MID + 5}H${px.toFixed(1)}L0 ${bottom}Z`} />
{teeth}
<g transform={`translate(${px.toFixed(1)} ${MID})`}>
<path className="slider-zipper__slider" d="M-10-10h20l-3 20h-14Z" />
<rect className="slider-zipper__pull" x="-5" y="4" width="10" height="24" rx="5" />
<rect className="slider-zipper__hole" x="-2" y="18" width="4" height="7" rx="2" />
</g>
</svg>
<input
className="slider-zipper__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Unzip"
aria-valuetext={`${v} percent open`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderZipper(el) on the mounted element instead.
<div class="slider-zipper">
<svg viewBox="0 0 220 68" aria-hidden="true">
<rect class="slider-zipper__inside" x="0" y="0" width="220" height="68" rx="8"></rect>
<path class="slider-zipper__fabric" d="M0 8a8 8 0 0 1 8-8H212 a8 8 0 0 1 8 8V29H120.0L0 18.1Z"></path>
<path class="slider-zipper__fabric" d="M0 60a8 8 0 0 0 8 8H212 a8 8 0 0 0 8-8V39H120.0L0 49.9Z"></path>
<rect class="slider-zipper__tooth" x="2" y="16.5" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="7" y="44.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="12" y="17.5" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="17" y="43.1" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="22" y="18.4" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="27" y="42.2" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="32" y="19.3" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="37" y="41.3" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="42" y="20.2" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="47" y="40.4" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="52" y="21.1" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="57" y="39.5" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="62" y="22.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="67" y="38.5" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="72" y="22.9" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="77" y="37.6" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="82" y="23.8" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="87" y="36.7" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="92" y="24.7" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="97" y="35.8" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="102" y="25.6" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="107" y="34.9" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="112" y="26.5" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="117" y="34.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="122" y="27.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="127" y="34.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="132" y="27.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="137" y="34.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="142" y="27.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="147" y="34.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="152" y="27.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="157" y="34.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="162" y="27.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="167" y="34.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="172" y="27.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="177" y="34.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="182" y="27.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="187" y="34.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="192" y="27.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="197" y="34.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="202" y="27.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="207" y="34.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="212" y="27.0" width="6" height="7" rx="1.5"></rect>
<rect class="slider-zipper__tooth" x="217" y="34.0" width="6" height="7" rx="1.5"></rect>
<g transform="translate(120.0 34)">
<path class="slider-zipper__slider" d="M-10-10h20l-3 20h-14Z"></path>
<rect class="slider-zipper__pull" x="-5" y="4" width="10" height="24" rx="5"></rect>
<rect class="slider-zipper__hole" x="-2" y="18" width="4" height="7" rx="2"></rect>
</g>
</svg>
<input class="slider-zipper__input" type="range" min="0" max="100" aria-label="Unzip" aria-valuetext="55 percent open" value="55"/>
</div>.slider-zipper { position: relative; width: 220px; height: 68px; }
.slider-zipper svg { display: block; width: 220px; height: 68px; overflow: visible; }
.slider-zipper__inside { fill: light-dark(#fbfcfd, #121417); }
.slider-zipper__fabric { fill: light-dark(#dbdde1, #2e3035); }
.slider-zipper__tooth { fill: light-dark(#50535b, #c6c8cd); }
.slider-zipper__slider { fill: light-dark(#42454c, #e5e6e9); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.slider-zipper__pull { fill: light-dark(#373a40, #f1f2f3); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.slider-zipper__hole { fill: light-dark(#dbdde1, #2e3035); }
.slider-zipper:has(.slider-zipper__input:focus-visible) .slider-zipper__slider { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2; }
.slider-zipper__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-zipper__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 68px; }
.slider-zipper__input::-moz-range-thumb { width: 20px; height: 68px; border: 0; }function initSliderZipper(root) {
var W = 220;
var MID = 34;
var OPEN = 20; // How far each edge has parted at the far end when fully unzipped.
var input = root.querySelector('.slider-zipper__input');
var fabric = root.querySelectorAll('.slider-zipper__fabric');
var teeth = root.querySelectorAll('.slider-zipper__tooth');
var pull = root.querySelector('svg g');
function update() {
var v = +input.value;
var px = 10 + (v / 100) * (W - 20);
var spread = (OPEN * px) / W;
// How far an edge has parted at x: nothing at the pull, most at the left end.
function off(x) { return x >= px ? 0 : (spread * (px - x)) / px; }
// Teeth come in pairs, top then bottom, every 10 units from x = 2.
for (var i = 0; i < teeth.length; i += 2) {
var x = 2 + (i / 2) * 10;
teeth[i].setAttribute('y', (MID - 7 - off(x + 3)).toFixed(1));
teeth[i + 1].setAttribute('y', (MID + off(x + 8)).toFixed(1));
}
var p = px.toFixed(1);
fabric[0].setAttribute('d', 'M0 8a8 8 0 0 1 8-8H' + (W - 8) + ' a8 8 0 0 1 8 8V' + (MID - 5) + 'H' + p + 'L0 ' + (MID - 5 - spread).toFixed(1) + 'Z');
fabric[1].setAttribute('d', 'M0 60a8 8 0 0 0 8 8H' + (W - 8) + ' a8 8 0 0 0 8-8V' + (MID + 5) + 'H' + p + 'L0 ' + (MID + 5 + spread).toFixed(1) + 'Z');
pull.setAttribute('transform', 'translate(' + p + ' ' + MID + ')');
input.setAttribute('aria-valuetext', v + ' percent open');
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-zipper').forEach(initSliderZipper);Goal: Create a "Zipper" slider.
Visual behavior: An unzip slider: drag the pull along and the zip opens behind it, the teeth parting along both edges in a widening V.
Technique:
- A real <input type="range"> covers the whole zip, transparent, so dragging anywhere moves the pull and the arrow, Page Up/Down, Home and End keys work natively. The drawing is aria-hidden.
- The fabric edges and every tooth are recalculated from the pull position: behind it each edge has parted in proportion to its distance from the pull, so the opening is a straight V and nothing moves ahead of the pull.
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 "Zipper" slider.
Visual behavior: An unzip slider: drag the pull along and the zip opens behind it, the teeth parting along both edges in a widening V.
Technique:
- A real <input type="range"> covers the whole zip, transparent, so dragging anywhere moves the pull and the arrow, Page Up/Down, Home and End keys work natively. The drawing is aria-hidden.
- The fabric edges and every tooth are recalculated from the pull position: behind it each edge has parted in proportion to its distance from the pull, so the opening is a straight V and nothing moves ahead of the pull.
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
An unzip slider: drag the pull along and the zip opens behind it, the teeth parting along both edges in a widening V.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, morph
- 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.