Push
import { useRef, useState } from 'react';
const W = 240;
const L = 8;
const TW = W - 16;
const G = 6;
const Y = 22;
const NAMES = ['Design', 'Build', 'Test', 'Ship'];
const x = (n: number) => L + (n / 100) * TW;
const css = `
.slider-push { position: relative; width: ${W}px; height: 74px; }
.slider-push__seg { position: absolute; top: ${Y - 5}px; height: 10px; border-radius: 5px; }
.slider-push__seg--0 { background: light-dark(#42454c, #e5e6e9); }
.slider-push__seg--1 { background: light-dark(#5f626a, #a7abb2); }
.slider-push__seg--2 { background: light-dark(#cbced3, #393c41); }
.slider-push__seg--3 { background: light-dark(#dbdde1, #2e3035); }
.slider-push__thumb {
position: absolute;
top: ${Y - 14}px;
width: 6px;
height: 28px;
margin-left: -3px;
border-radius: 3px;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 1px 4px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.5));
cursor: grab;
touch-action: none;
transition: transform 0.22s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-push__thumb::before { content: ''; position: absolute; inset: -8px -9px; }
.slider-push__thumb:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-push__thumb--touch { transform: scale(0.55, 1.1); }
.slider-push__label { position: absolute; top: ${Y + 14}px; transform: translateX(-50%); font-size: 11px; line-height: 1.3; text-align: center; color: light-dark(#5f626a, #a7abb2); white-space: nowrap; }
.slider-push__label b { display: block; color: light-dark(#373a40, #f1f2f3); font-size: 12px; font-weight: 500; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) {
.slider-push__thumb { transition: none; }
}
`;
// Move one divider and shove its neighbours along, never letting a segment drop below G.
function shove(p: number[], i: number, to: number) {
const n = p.slice();
n[i] = Math.round(Math.min(100 - (3 - i) * G, Math.max((i + 1) * G, to)));
for (let j = i + 1; j < 3; j++) n[j] = Math.max(n[j], n[j - 1] + G);
for (let j = i - 1; j >= 0; j--) n[j] = Math.min(n[j], n[j + 1] - G);
return n;
}
export function PushSlider() {
const [vals, setVals] = useState([25, 65, 85]);
const box = useRef<HTMLDivElement>(null);
const bounds = [0, ...vals, 100];
const move = (i: number, to: number) => setVals((p) => shove(p, i, to));
return (
<>
<style>
{css}
</style>
<div className="slider-push" ref={box}>
{NAMES.map((name, j) => {
const a = x(bounds[j]) + (j ? 4 : 0);
const b = x(bounds[j + 1]) - (j < 3 ? 4 : 0);
const wide = b - a;
const pct = bounds[j + 1] - bounds[j];
return (
<span key={name}>
<span className={`slider-push__seg slider-push__seg--${j}`} style={{ left: a, width: wide }} aria-hidden="true" />
{wide >= 18 && (
<span className="slider-push__label" style={{ left: (a + b) / 2 }} aria-hidden="true">
{wide >= 40 ? name : ' '}
<b>{pct}%</b>
</span>
)}
</span>
);
})}
{vals.map((n, i) => (
<span
key={i}
className={`slider-push__thumb${vals[i + 1] - n <= G || n - (vals[i - 1] ?? -99) <= G ? ' slider-push__thumb--touch' : ''}`}
style={{ left: x(n) }}
role="slider"
tabIndex={0}
aria-label={`${NAMES[i]} and ${NAMES[i + 1]}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={n}
aria-valuetext={`${NAMES[i]} ends at ${n}%`}
onPointerDown={(e) => e.currentTarget.setPointerCapture(e.pointerId)}
onPointerMove={(e) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
const r = box.current!.getBoundingClientRect();
move(i, ((e.clientX - r.left - L) / TW) * 100);
}}
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') move(i, 0);
else if (e.key === 'End') move(i, 100);
else if (by[e.key]) move(i, n + by[e.key]);
else return;
e.preventDefault();
}}
/>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderPush(el) on the mounted element instead.
<div class="slider-push">
<span>
<span class="slider-push__seg slider-push__seg--0" style="left:8px;width:52px" aria-hidden="true"></span>
<span class="slider-push__label" style="left:34px" aria-hidden="true">
Design
<b>25%</b>
</span>
</span>
<span>
<span class="slider-push__seg slider-push__seg--1" style="left:68px;width:81.6px" aria-hidden="true"></span>
<span class="slider-push__label" style="left:108.8px" aria-hidden="true">
Build
<b>40%</b>
</span>
</span>
<span>
<span class="slider-push__seg slider-push__seg--2" style="left:157.6px;width:36.8px" aria-hidden="true"></span>
<span class="slider-push__label" style="left:176px" aria-hidden="true">
<b>20%</b>
</span>
</span>
<span>
<span class="slider-push__seg slider-push__seg--3" style="left:202.4px;width:29.6px" aria-hidden="true"></span>
<span class="slider-push__label" style="left:217.2px" aria-hidden="true">
<b>15%</b>
</span>
</span>
<span class="slider-push__thumb" style="left:64px" role="slider" tabindex="0" aria-label="Design and Build" aria-valuemin="0" aria-valuemax="100" aria-valuenow="25" aria-valuetext="Design ends at 25%"></span>
<span class="slider-push__thumb" style="left:153.6px" role="slider" tabindex="0" aria-label="Build and Test" aria-valuemin="0" aria-valuemax="100" aria-valuenow="65" aria-valuetext="Build ends at 65%"></span>
<span class="slider-push__thumb" style="left:198.4px" role="slider" tabindex="0" aria-label="Test and Ship" aria-valuemin="0" aria-valuemax="100" aria-valuenow="85" aria-valuetext="Test ends at 85%"></span>
</div>.slider-push { position: relative; width: 240px; height: 74px; }
.slider-push__seg { position: absolute; top: 17px; height: 10px; border-radius: 5px; }
.slider-push__seg--0 { background: light-dark(#42454c, #e5e6e9); }
.slider-push__seg--1 { background: light-dark(#5f626a, #a7abb2); }
.slider-push__seg--2 { background: light-dark(#cbced3, #393c41); }
.slider-push__seg--3 { background: light-dark(#dbdde1, #2e3035); }
.slider-push__thumb {
position: absolute;
top: 8px;
width: 6px;
height: 28px;
margin-left: -3px;
border-radius: 3px;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 1px 4px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.5));
cursor: grab;
touch-action: none;
transition: transform 0.22s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-push__thumb::before { content: ''; position: absolute; inset: -8px -9px; }
.slider-push__thumb:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-push__thumb--touch { transform: scale(0.55, 1.1); }
.slider-push__label { position: absolute; top: 36px; transform: translateX(-50%); font-size: 11px; line-height: 1.3; text-align: center; color: light-dark(#5f626a, #a7abb2); white-space: nowrap; }
.slider-push__label b { display: block; color: light-dark(#373a40, #f1f2f3); font-size: 12px; font-weight: 500; font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) {
.slider-push__thumb { transition: none; }
}function initSliderPush(root) {
var L = 8;
var TW = 224;
var G = 6;
var NAMES = ['Design', 'Build', 'Test', 'Ship'];
var thumbs = Array.prototype.slice.call(root.querySelectorAll('.slider-push__thumb'));
var segs = root.querySelectorAll('.slider-push__seg');
var labels = root.querySelectorAll('.slider-push__label');
var vals = thumbs.map(function (t) { return +t.getAttribute('aria-valuenow'); });
function x(n) { return L + (n / 100) * TW; }
function draw() {
var bounds = [0].concat(vals, [100]);
NAMES.forEach(function (name, j) {
var a = x(bounds[j]) + (j ? 4 : 0);
var b = x(bounds[j + 1]) - (j < 3 ? 4 : 0);
var wide = b - a;
segs[j].style.left = a + 'px';
segs[j].style.width = wide + 'px';
// Narrow segments drop their name, then their figure.
var label = labels[j];
label.hidden = wide < 18;
label.style.left = (a + b) / 2 + 'px';
label.firstChild.nodeValue = wide >= 40 ? name : ' ';
label.querySelector('b').textContent = bounds[j + 1] - bounds[j] + '%';
});
thumbs.forEach(function (t, i) {
var touching = (vals[i + 1] !== undefined && vals[i + 1] - vals[i] <= G) || (vals[i - 1] !== undefined && vals[i] - vals[i - 1] <= G);
t.classList.toggle('slider-push__thumb--touch', touching);
t.style.left = x(vals[i]) + 'px';
t.setAttribute('aria-valuenow', vals[i]);
t.setAttribute('aria-valuetext', NAMES[i] + ' ends at ' + vals[i] + '%');
});
}
// Move one divider and shove its neighbours along, never letting a segment drop below G.
function move(i, to) {
vals[i] = Math.round(Math.min(100 - (3 - i) * G, Math.max((i + 1) * G, to)));
for (var j = i + 1; j < 3; j++) vals[j] = Math.max(vals[j], vals[j - 1] + G);
for (var k = i - 1; k >= 0; k--) vals[k] = Math.min(vals[k], vals[k + 1] - G);
draw();
}
var offs = thumbs.map(function (t, i) {
function down(e) { t.setPointerCapture(e.pointerId); }
function drag(e) {
if (!t.hasPointerCapture(e.pointerId)) return;
move(i, ((e.clientX - root.getBoundingClientRect().left - L) / TW) * 100);
}
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') move(i, 0);
else if (e.key === 'End') move(i, 100);
else if (by) move(i, vals[i] + by);
else return;
e.preventDefault();
}
t.addEventListener('pointerdown', down);
t.addEventListener('pointermove', drag);
t.addEventListener('keydown', key);
return function () {
t.removeEventListener('pointerdown', down);
t.removeEventListener('pointermove', drag);
t.removeEventListener('keydown', key);
};
});
return function () {
offs.forEach(function (off) { off(); });
};
}
document.querySelectorAll('.slider-push').forEach(initSliderPush);Goal: Create a "Push" slider.
Visual behavior: Three dividers on one track that cannot pass each other: drag one into the next and it shoves it along, so the four segments always add up to the whole.
Technique:
- Each handle is a 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.
- Moving a divider clamps it so each segment keeps a minimum width, then walks outwards pushing each neighbour just far enough to keep that gap, in both directions. Dividers in contact squash a little through a springy transition.
- Each segment is a rounded bar between the dividers; its label shows the name and percentage, drops the name when the segment is narrow and hides when it is too narrow for the figure.
Accessibility: Three role="slider" handles, each named for the segments it separates, with aria-valuenow and an aria-valuetext; arrows step by 1, Shift by 10, with Page Up/Down, Home and End. Visible focus rings. Under prefers-reduced-motion the squash has no transition.
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 "Push" slider.
Visual behavior: Three dividers on one track that cannot pass each other: drag one into the next and it shoves it along, so the four segments always add up to the whole.
Technique:
- Each handle is a 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.
- Moving a divider clamps it so each segment keeps a minimum width, then walks outwards pushing each neighbour just far enough to keep that gap, in both directions. Dividers in contact squash a little through a springy transition.
- Each segment is a rounded bar between the dividers; its label shows the name and percentage, drops the name when the segment is narrow and hides when it is too narrow for the figure.
Accessibility: Three role="slider" handles, each named for the segments it separates, with aria-valuenow and an aria-valuetext; arrows step by 1, Shift by 10, with Page Up/Down, Home and End. Visible focus rings. Under prefers-reduced-motion the squash has no transition.
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
Three dividers on one track that cannot pass each other: drag one into the next and it shoves it along, so the four segments always add up to the whole.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, spring
- Techniques
- event position, css transition
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Three role="slider" handles, each named for the segments it separates, with aria-valuenow and an aria-valuetext; arrows step by 1, Shift by 10, with Page Up/Down, Home and End. Visible focus rings. Under prefers-reduced-motion the squash has no transition.