Clamp
import { useEffect, useRef, useState } from 'react';
const W = 240;
const JW = 14;
const MAX = 80;
const TOP = 6;
const BOT = 50;
// A jaw with a serrated inner face; `side` 1 is the left jaw, -1 the right.
const jaw = (edge: number, side: 1 | -1) => {
const face = edge + side * JW;
let p = `${edge},${TOP} ${face},${TOP}`;
for (let y = TOP; y < BOT; y += 4) p += ` ${face + side * 2.5},${y + 2} ${face},${y + 4}`;
return `${p} ${edge},${BOT}`;
};
const css = `
.slider-clamp { position: relative; width: ${W}px; height: 92px; border-radius: 6px; touch-action: none; }
.slider-clamp:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; }
.slider-clamp__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 66px; overflow: visible; }
.slider-clamp__jaw { fill: light-dark(#50535b, #c6c8cd); }
.slider-clamp__rod { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 4; stroke-linecap: round; }
.slider-clamp__thread { stroke: light-dark(#6d7179, #7d8087); stroke-width: 4; stroke-dasharray: 1.2 2.4; }
.slider-clamp__word {
position: absolute;
left: 50%;
top: ${(TOP + BOT) / 2 - 11}px;
height: 22px;
font-size: 16px;
font-weight: 600;
line-height: 22px;
color: light-dark(#373a40, #f1f2f3);
white-space: nowrap;
pointer-events: none;
}
.slider-clamp__grip { position: absolute; top: 0; width: ${JW + 16}px; height: 66px; cursor: ew-resize; }
.slider-clamp__value { position: absolute; left: 0; right: 0; top: 70px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-clamp__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
`;
export function ClampSlider() {
const [inset, setInset] = useState(30);
const [d, setD] = useState(30);
const [nat, setNat] = useState(66);
const word = useRef<HTMLSpanElement>(null);
const box = useRef<HTMLDivElement>(null);
const side = useRef<1 | -1>(1);
const st = useRef({ x: 30, v: 0, raf: 0 });
useEffect(() => setNat(word.current!.offsetWidth), []);
useEffect(() => {
const q = st.current;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
q.x = inset;
setD(inset);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.v += (-320 * (q.x - inset) - 30 * q.v) * dt;
q.x += q.v * dt;
if (Math.abs(q.x - inset) < 0.05 && Math.abs(q.v) < 0.5) {
q.x = inset;
q.v = 0;
setD(inset);
return;
}
setD(q.x);
q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [inset]);
const set = (n: number) => setInset(Math.round(Math.min(MAX, Math.max(0, n))));
// Once the jaws reach the word they squash it, and it bulges a little to keep its bulk.
const room = W - 2 * (JW + d) - 8;
const sx = Math.min(1, room / nat);
const drag = (e: React.PointerEvent) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
const px = e.clientX - box.current!.getBoundingClientRect().left;
set(side.current === 1 ? px - JW / 2 : W - px - JW / 2);
};
return (
<>
<style>
{css}
</style>
<div
ref={box}
className="slider-clamp"
role="slider"
tabIndex={0}
aria-label="Inset"
aria-valuemin={0}
aria-valuemax={MAX}
aria-valuenow={inset}
aria-valuetext={`${inset} pixels`}
onKeyDown={(e) => {
const step = e.shiftKey ? 10 : 1;
const by: Record<string, number> = { ArrowRight: step, ArrowUp: step, ArrowLeft: -step, ArrowDown: -step, PageUp: 10, PageDown: -10 };
if (e.key === 'Home') set(0);
else if (e.key === 'End') set(MAX);
else if (by[e.key]) set(inset + by[e.key]);
else return;
e.preventDefault();
}}
>
<svg className="slider-clamp__svg" aria-hidden="true">
<line className="slider-clamp__rod" x1={4} y1={59} x2={W - 4} y2={59} />
<line className="slider-clamp__thread" x1={8} y1={59} x2={W - 8} y2={59} strokeDashoffset={-d * 0.9} />
<polygon className="slider-clamp__jaw slider-clamp__jaw--l" points={jaw(d, 1)} />
<polygon className="slider-clamp__jaw slider-clamp__jaw--r" points={jaw(W - d, -1)} />
<rect className="slider-clamp__jaw slider-clamp__jaw--l" x={d + 3} y={BOT} width={8} height={9} />
<rect className="slider-clamp__jaw slider-clamp__jaw--r" x={W - d - 11} y={BOT} width={8} height={9} />
</svg>
<span ref={word} className="slider-clamp__word" style={{ transform: `translateX(-50%) scale(${sx}, ${1 + (1 - sx) * 0.3})` }} aria-hidden="true">
Content
</span>
{([1, -1] as const).map((s) => (
<span
key={s}
className={`slider-clamp__grip slider-clamp__grip--${s === 1 ? 'l' : 'r'}`}
style={{ left: s === 1 ? d - 8 : W - d - JW - 8 }}
onPointerDown={(e) => {
side.current = s;
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={drag}
aria-hidden="true"
/>
))}
<p className="slider-clamp__value" aria-hidden="true">
<span>Inset</span>
<b>{inset} px</b>
</p>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderClamp(el) on the mounted element instead.
<div class="slider-clamp" role="slider" tabindex="0" aria-label="Inset" aria-valuemin="0" aria-valuemax="80" aria-valuenow="30" aria-valuetext="30 pixels">
<svg class="slider-clamp__svg" aria-hidden="true">
<line class="slider-clamp__rod" x1="4" y1="59" x2="236" y2="59"></line>
<line class="slider-clamp__thread" x1="8" y1="59" x2="232" y2="59" stroke-dashoffset="-27"></line>
<polygon class="slider-clamp__jaw slider-clamp__jaw--l" points="30,6 44,6 46.5,8 44,10 46.5,12 44,14 46.5,16 44,18 46.5,20 44,22 46.5,24 44,26 46.5,28 44,30 46.5,32 44,34 46.5,36 44,38 46.5,40 44,42 46.5,44 44,46 46.5,48 44,50 30,50"></polygon>
<polygon class="slider-clamp__jaw slider-clamp__jaw--r" points="210,6 196,6 193.5,8 196,10 193.5,12 196,14 193.5,16 196,18 193.5,20 196,22 193.5,24 196,26 193.5,28 196,30 193.5,32 196,34 193.5,36 196,38 193.5,40 196,42 193.5,44 196,46 193.5,48 196,50 210,50"></polygon>
<rect class="slider-clamp__jaw slider-clamp__jaw--l" x="33" y="50" width="8" height="9"></rect>
<rect class="slider-clamp__jaw slider-clamp__jaw--r" x="199" y="50" width="8" height="9"></rect>
</svg>
<span class="slider-clamp__word" style="transform:translateX(-50%) scale(1, 1)" aria-hidden="true">Content</span>
<span class="slider-clamp__grip slider-clamp__grip--l" style="left:22px" aria-hidden="true"></span>
<span class="slider-clamp__grip slider-clamp__grip--r" style="left:188px" aria-hidden="true"></span>
<p class="slider-clamp__value" aria-hidden="true">
<span>Inset</span>
<b>30 px</b>
</p>
</div>.slider-clamp { position: relative; width: 240px; height: 92px; border-radius: 6px; touch-action: none; }
.slider-clamp:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; }
.slider-clamp__svg { position: absolute; left: 0; top: 0; width: 240px; height: 66px; overflow: visible; }
.slider-clamp__jaw { fill: light-dark(#50535b, #c6c8cd); }
.slider-clamp__rod { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 4; stroke-linecap: round; }
.slider-clamp__thread { stroke: light-dark(#6d7179, #7d8087); stroke-width: 4; stroke-dasharray: 1.2 2.4; }
.slider-clamp__word {
position: absolute;
left: 50%;
top: 17px;
height: 22px;
font-size: 16px;
font-weight: 600;
line-height: 22px;
color: light-dark(#373a40, #f1f2f3);
white-space: nowrap;
pointer-events: none;
}
.slider-clamp__grip { position: absolute; top: 0; width: 30px; height: 66px; cursor: ew-resize; }
.slider-clamp__value { position: absolute; left: 0; right: 0; top: 70px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-clamp__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }function initSliderClamp(root) {
var W = 240;
var JW = 14;
var MAX = 80;
var TOP = 6;
var BOT = 50;
var thread = root.querySelector('.slider-clamp__thread');
var jawL = root.querySelector('polygon.slider-clamp__jaw--l');
var jawR = root.querySelector('polygon.slider-clamp__jaw--r');
var footL = root.querySelector('rect.slider-clamp__jaw--l');
var footR = root.querySelector('rect.slider-clamp__jaw--r');
var word = root.querySelector('.slider-clamp__word');
var gripL = root.querySelector('.slider-clamp__grip--l');
var gripR = root.querySelector('.slider-clamp__grip--r');
var label = root.querySelector('.slider-clamp__value b');
var inset = +root.getAttribute('aria-valuenow');
var d = inset;
var v = 0;
var side = 1;
var raf = 0;
var nat = word.offsetWidth;
// A jaw with a serrated inner face; side 1 is the left jaw, -1 the right.
function jaw(edge, s) {
var face = edge + s * JW;
var p = edge + ',' + TOP + ' ' + face + ',' + TOP;
for (var y = TOP; y < BOT; y += 4) p += ' ' + (face + s * 2.5) + ',' + (y + 2) + ' ' + face + ',' + (y + 4);
return p + ' ' + edge + ',' + BOT;
}
function draw() {
jawL.setAttribute('points', jaw(d, 1));
jawR.setAttribute('points', jaw(W - d, -1));
footL.setAttribute('x', d + 3);
footR.setAttribute('x', W - d - 11);
thread.setAttribute('stroke-dashoffset', -d * 0.9);
gripL.style.left = d - 8 + 'px';
gripR.style.left = W - d - JW - 8 + 'px';
// Once the jaws reach the word they squash it, and it bulges a little to keep its bulk.
var sx = Math.min(1, (W - 2 * (JW + d) - 8) / nat);
word.style.transform = 'translateX(-50%) scale(' + sx + ', ' + (1 + (1 - sx) * 0.3) + ')';
}
function run() {
cancelAnimationFrame(raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
d = inset;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
v += (-320 * (d - inset) - 30 * v) * dt;
d += v * dt;
if (Math.abs(d - inset) < 0.05 && Math.abs(v) < 0.5) {
d = inset;
v = 0;
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function set(n) {
inset = Math.round(Math.min(MAX, Math.max(0, n)));
root.setAttribute('aria-valuenow', inset);
root.setAttribute('aria-valuetext', inset + ' pixels');
label.textContent = inset + ' px';
run();
}
function key(e) {
var step = e.shiftKey ? 10 : 1;
var by = { ArrowRight: step, ArrowUp: step, ArrowLeft: -step, ArrowDown: -step, PageUp: 10, PageDown: -10 }[e.key];
if (e.key === 'Home') set(0);
else if (e.key === 'End') set(MAX);
else if (by) set(inset + by);
else return;
e.preventDefault();
}
// Drag either jaw: both move, mirrored.
function grab(s) {
return function (e) {
side = s;
e.currentTarget.setPointerCapture(e.pointerId);
};
}
function drag(e) {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
var px = e.clientX - root.getBoundingClientRect().left;
set(side === 1 ? px - JW / 2 : W - px - JW / 2);
}
var grabL = grab(1);
var grabR = grab(-1);
root.addEventListener('keydown', key);
gripL.addEventListener('pointerdown', grabL);
gripR.addEventListener('pointerdown', grabR);
gripL.addEventListener('pointermove', drag);
gripR.addEventListener('pointermove', drag);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('keydown', key);
gripL.removeEventListener('pointerdown', grabL);
gripR.removeEventListener('pointerdown', grabR);
gripL.removeEventListener('pointermove', drag);
gripR.removeEventListener('pointermove', drag);
};
}
document.querySelectorAll('.slider-clamp').forEach(initSliderClamp);Goal: Create a "Clamp" slider.
Visual behavior: An inset slider drawn as a vice: drag either jaw and both close in from the ends together, and once they reach the word between them they squeeze it narrower.
Technique:
- The whole vice is one focusable element with role="slider", aria-valuemin, aria-valuemax and aria-valuenow, driven by pointer events with pointer capture and by the arrow keys, Page Up/Down, Home and End. The drawing is aria-hidden.
- Each jaw is a polygon with a serrated inner face; grips over the jaws take pointer capture, and either one sets the inset from the pointer's distance to its own end, so both move mirrored. The threads on the screw rod shift with the inset, as if it turns.
- The word keeps its natural width until the gap is too small for it, then scales down horizontally and up a little vertically, keeping some of its bulk.
Accessibility: role="slider" with an aria-label, aria-valuenow and an aria-valuetext in pixels; arrows step by 1, Shift by 10, with Page Up/Down, Home and End. A visible focus ring. Under prefers-reduced-motion the jaws move 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 "Clamp" slider.
Visual behavior: An inset slider drawn as a vice: drag either jaw and both close in from the ends together, and once they reach the word between them they squeeze it narrower.
Technique:
- The whole vice is one focusable element with role="slider", aria-valuemin, aria-valuemax and aria-valuenow, driven by pointer events with pointer capture and by the arrow keys, Page Up/Down, Home and End. The drawing is aria-hidden.
- Each jaw is a polygon with a serrated inner face; grips over the jaws take pointer capture, and either one sets the inset from the pointer's distance to its own end, so both move mirrored. The threads on the screw rod shift with the inset, as if it turns.
- The word keeps its natural width until the gap is too small for it, then scales down horizontally and up a little vertically, keeping some of its bulk.
Accessibility: role="slider" with an aria-label, aria-valuenow and an aria-valuetext in pixels; arrows step by 1, Shift by 10, with Page Up/Down, Home and End. A visible focus ring. Under prefers-reduced-motion the jaws move 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
An inset slider drawn as a vice: drag either jaw and both close in from the ends together, and once they reach the word between them they squeeze it narrower.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring
- Techniques
- svg, spring, raf, event position
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
role="slider" with an aria-label, aria-valuenow and an aria-valuetext in pixels; arrows step by 1, Shift by 10, with Page Up/Down, Home and End. A visible focus ring. Under prefers-reduced-motion the jaws move without the spring.