0102030405060708090100
Focus40%
import { useEffect, useRef, useState } from 'react';
const W = 240;
const T = 18;
const Y = 44;
const MAX = 1000;
const at = (n: number) => T / 2 + (n / MAX) * (W - T);
const css = `
.slider-delta { position: relative; width: ${W}px; height: 76px; }
.slider-delta__rail, .slider-delta__fill, .slider-delta__change { position: absolute; top: ${Y - 3}px; height: 6px; border-radius: 3px; }
.slider-delta__rail { left: 0; width: ${W}px; background: light-dark(#ebedf0, #232529); }
.slider-delta__fill { left: 0; background: light-dark(#42454c, #e5e6e9); }
.slider-delta__change { box-sizing: border-box; }
.slider-delta__change--up { background: repeating-linear-gradient(-45deg, light-dark(#50535b, #c6c8cd) 0 3px, light-dark(#6d7179, #7d8087) 3px 6px); }
.slider-delta__change--down { border: 1px dashed light-dark(#5f626a, #a7abb2); }
.slider-delta__ghost {
position: absolute;
top: ${Y - T / 2}px;
width: ${T}px;
height: ${T}px;
margin-left: ${-T / 2}px;
box-sizing: border-box;
border: 1.5px dashed light-dark(#5f626a, #a7abb2);
border-radius: 50%;
}
.slider-delta__bracket { position: absolute; top: 14px; height: 8px; box-sizing: border-box; border: 1.5px solid light-dark(#5f626a, #a7abb2); border-bottom: 0; }
.slider-delta__label { position: absolute; top: -4px; transform: translateX(-50%); font-size: 12px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); font-variant-numeric: tabular-nums; white-space: nowrap; }
.slider-delta--settle .slider-delta__ghost,
.slider-delta--settle .slider-delta__bracket,
.slider-delta--settle .slider-delta__change,
.slider-delta--settle .slider-delta__label {
transition: left 0.45s cubic-bezier(0.3, 1.2, 0.5, 1), width 0.45s cubic-bezier(0.3, 1.2, 0.5, 1), opacity 0.45s;
opacity: 0;
}
.slider-delta--settle .slider-delta__fill { transition: width 0.45s cubic-bezier(0.3, 1.2, 0.5, 1); }
.slider-delta__thumb { position: absolute; top: ${Y - T / 2}px; width: ${T}px; height: ${T}px; margin-left: ${-T / 2}px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 2px 5px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)); }
.slider-delta:has(.slider-delta__input:focus-visible) .slider-delta__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-delta__value { position: absolute; left: 0; right: 0; top: ${Y + 16}px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-delta__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-delta__input { position: absolute; left: 0; top: ${Y - 18}px; width: 100%; height: 36px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-delta__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${T}px; height: 36px; }
.slider-delta__input::-moz-range-thumb { width: ${T}px; height: 36px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-delta--settle .slider-delta__ghost,
.slider-delta--settle .slider-delta__bracket,
.slider-delta--settle .slider-delta__change,
.slider-delta--settle .slider-delta__label { transition: opacity 0.2s; }
.slider-delta--settle .slider-delta__fill { transition: none; }
}
`;
const money = (n: number) => `$${n}`;
export function DeltaSlider() {
const [v, setV] = useState(480);
const [start, setStart] = useState<number | null>(null);
const [settle, setSettle] = useState(false);
const timers = useRef({ key: 0, done: 0 });
// A change starts when you grab the thumb (or press a key) and ends when you let go (or stop pressing).
const begin = () => {
clearTimeout(timers.current.done);
setSettle(false);
setStart((s) => (s === null || settle ? v : s));
};
const commit = () => {
setSettle(true);
timers.current.done = window.setTimeout(() => {
setStart(null);
setSettle(false);
}, 460);
};
useEffect(() => {
const t = timers.current;
return () => {
clearTimeout(t.key);
clearTimeout(t.done);
};
}, []);
const s = start ?? v;
const x = at(v);
// Settling, the ghost and the bracket collapse onto the thumb.
const gx = settle ? x : at(s);
const lo = Math.min(gx, x);
const diff = v - s;
const sign = diff > 0 ? '+' : diff < 0 ? '−' : '';
return (
<>
<style>
{css}
</style>
<div className={`slider-delta${settle ? ' slider-delta--settle' : ''}`}>
<span className="slider-delta__rail" aria-hidden="true" />
<span className="slider-delta__fill" style={{ width: settle ? x : Math.min(x, at(s)) }} aria-hidden="true" />
{start !== null && (
<>
<span className={`slider-delta__change slider-delta__change--${diff >= 0 ? 'up' : 'down'}`} style={{ left: lo, width: Math.abs(x - gx) }} aria-hidden="true" />
<span className="slider-delta__ghost" style={{ left: gx }} aria-hidden="true" />
<span className="slider-delta__bracket" style={{ left: lo, width: Math.abs(x - gx) }} aria-hidden="true" />
{diff !== 0 && (
<span className="slider-delta__label" style={{ left: (gx + x) / 2 }} aria-hidden="true">
{sign}
{money(Math.abs(diff))}
</span>
)}
</>
)}
<span className="slider-delta__thumb" style={{ left: x }} aria-hidden="true" />
<p className="slider-delta__value" aria-hidden="true">
<span>Budget</span>
<b>{money(v)}</b>
</p>
<input
className="slider-delta__input"
type="range"
min={0}
max={MAX}
step={10}
value={v}
onPointerDown={begin}
onPointerUp={commit}
onPointerCancel={commit}
onKeyDown={(e) => {
if (!/^(Arrow|Page|Home|End)/.test(e.key)) return;
clearTimeout(timers.current.key);
begin();
}}
onKeyUp={() => {
clearTimeout(timers.current.key);
timers.current.key = window.setTimeout(commit, 900);
}}
onChange={(e) => setV(+e.target.value)}
aria-label="Budget"
aria-valuetext={money(v)}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderDelta(el) on the mounted element instead.
<div class="slider-delta">
<span class="slider-delta__rail" aria-hidden="true"></span>
<span class="slider-delta__fill" style="width:115.56px" aria-hidden="true"></span>
<span class="slider-delta__thumb" style="left:115.56px" aria-hidden="true"></span>
<p class="slider-delta__value" aria-hidden="true">
<span>Budget</span>
<b>$480</b>
</p>
<input class="slider-delta__input" type="range" min="0" max="1000" step="10" aria-label="Budget" aria-valuetext="$480" value="480"/>
</div>.slider-delta { position: relative; width: 240px; height: 76px; }
.slider-delta__rail, .slider-delta__fill, .slider-delta__change { position: absolute; top: 41px; height: 6px; border-radius: 3px; }
.slider-delta__rail { left: 0; width: 240px; background: light-dark(#ebedf0, #232529); }
.slider-delta__fill { left: 0; background: light-dark(#42454c, #e5e6e9); }
.slider-delta__change { box-sizing: border-box; }
.slider-delta__change--up { background: repeating-linear-gradient(-45deg, light-dark(#50535b, #c6c8cd) 0 3px, light-dark(#6d7179, #7d8087) 3px 6px); }
.slider-delta__change--down { border: 1px dashed light-dark(#5f626a, #a7abb2); }
.slider-delta__ghost {
position: absolute;
top: 35px;
width: 18px;
height: 18px;
margin-left: -9px;
box-sizing: border-box;
border: 1.5px dashed light-dark(#5f626a, #a7abb2);
border-radius: 50%;
}
.slider-delta__bracket { position: absolute; top: 14px; height: 8px; box-sizing: border-box; border: 1.5px solid light-dark(#5f626a, #a7abb2); border-bottom: 0; }
.slider-delta__label { position: absolute; top: -4px; transform: translateX(-50%); font-size: 12px; font-weight: 600; color: light-dark(#373a40, #f1f2f3); font-variant-numeric: tabular-nums; white-space: nowrap; }
.slider-delta--settle .slider-delta__ghost,
.slider-delta--settle .slider-delta__bracket,
.slider-delta--settle .slider-delta__change,
.slider-delta--settle .slider-delta__label {
transition: left 0.45s cubic-bezier(0.3, 1.2, 0.5, 1), width 0.45s cubic-bezier(0.3, 1.2, 0.5, 1), opacity 0.45s;
opacity: 0;
}
.slider-delta--settle .slider-delta__fill { transition: width 0.45s cubic-bezier(0.3, 1.2, 0.5, 1); }
.slider-delta__thumb { position: absolute; top: 35px; width: 18px; height: 18px; margin-left: -9px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 2px 5px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)); }
.slider-delta:has(.slider-delta__input:focus-visible) .slider-delta__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-delta__value { position: absolute; left: 0; right: 0; top: 60px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-delta__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-delta__input { position: absolute; left: 0; top: 26px; width: 100%; height: 36px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-delta__input::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 36px; }
.slider-delta__input::-moz-range-thumb { width: 18px; height: 36px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-delta--settle .slider-delta__ghost,
.slider-delta--settle .slider-delta__bracket,
.slider-delta--settle .slider-delta__change,
.slider-delta--settle .slider-delta__label { transition: opacity 0.2s; }
.slider-delta--settle .slider-delta__fill { transition: none; }
}function initSliderDelta(root) {
var W = 240;
var T = 18;
var MAX = 1000;
var input = root.querySelector('.slider-delta__input');
var fill = root.querySelector('.slider-delta__fill');
var thumb = root.querySelector('.slider-delta__thumb');
var label = root.querySelector('.slider-delta__value b');
// The change, the ghost, the bracket and the signed label only exist while a change is in progress.
function part(name) {
var el = document.createElement('span');
el.className = 'slider-delta__' + name;
el.setAttribute('aria-hidden', 'true');
el.hidden = true;
root.insertBefore(el, thumb);
return el;
}
var change = part('change');
var ghost = part('ghost');
var bracket = part('bracket');
var tag = part('label');
var start = null;
var settling = false;
var keyTimer = 0;
var doneTimer = 0;
function at(n) { return T / 2 + (n / MAX) * (W - T); }
function draw() {
var v = +input.value;
var x = at(v);
var s = start === null ? v : start;
// Settling, the ghost and the bracket collapse onto the thumb.
var gx = settling ? x : at(s);
var lo = Math.min(gx, x);
var diff = v - s;
var shown = start !== null;
root.classList.toggle('slider-delta--settle', settling);
fill.style.width = (settling || !shown ? x : Math.min(x, at(s))) + 'px';
change.hidden = ghost.hidden = bracket.hidden = !shown;
tag.hidden = !shown || diff === 0;
change.className = 'slider-delta__change slider-delta__change--' + (diff >= 0 ? 'up' : 'down');
change.style.left = bracket.style.left = lo + 'px';
change.style.width = bracket.style.width = Math.abs(x - gx) + 'px';
ghost.style.left = gx + 'px';
tag.style.left = (gx + x) / 2 + 'px';
tag.textContent = (diff > 0 ? '+' : diff < 0 ? '−' : '') + '$' + Math.abs(diff);
thumb.style.left = x + 'px';
label.textContent = '$' + v;
input.setAttribute('aria-valuetext', '$' + v);
}
// A change starts when you grab the thumb (or press a key) and ends when you let go (or stop pressing).
function begin() {
clearTimeout(doneTimer);
if (start === null || settling) start = +input.value;
settling = false;
draw();
}
function commit() {
if (start === null) return;
settling = true;
draw();
doneTimer = setTimeout(function () {
start = null;
settling = false;
draw();
}, 460);
}
function keydown(e) {
if (!/^(Arrow|Page|Home|End)/.test(e.key)) return;
clearTimeout(keyTimer);
begin();
}
function keyup() {
clearTimeout(keyTimer);
keyTimer = setTimeout(commit, 900);
}
input.addEventListener('pointerdown', begin);
input.addEventListener('pointerup', commit);
input.addEventListener('pointercancel', commit);
input.addEventListener('keydown', keydown);
input.addEventListener('keyup', keyup);
input.addEventListener('input', draw);
return function () {
clearTimeout(keyTimer);
clearTimeout(doneTimer);
input.removeEventListener('pointerdown', begin);
input.removeEventListener('pointerup', commit);
input.removeEventListener('pointercancel', commit);
input.removeEventListener('keydown', keydown);
input.removeEventListener('keyup', keyup);
input.removeEventListener('input', draw);
};
}
document.querySelectorAll('.slider-delta').forEach(initSliderDelta);Goal: Create a "Delta" slider.
Visual behavior: Grab the thumb and a ghost stays at the value you started from, with a bracket and a signed change between them; let go and the ghost slides over and joins the thumb.
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.
- A change begins on pointerdown or the first arrow, Page or Home/End key, and records the start value; it ends on pointerup or 900ms after the last key. The span between start and value is striped when the value went up and outlined with a dashed border when it went down, and the label reads the difference with a plus or a true minus sign.
- Ending adds a settle class that turns on transitions and points the ghost, the bracket and the change span at the thumb, so they collapse into it and fade; after the transition the extras are removed.
Accessibility: A native range input with an aria-label and a dollar aria-valuetext; keyboard focus rings the thumb. Under prefers-reduced-motion the change fades out without sliding.
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 "Delta" slider.
Visual behavior: Grab the thumb and a ghost stays at the value you started from, with a bracket and a signed change between them; let go and the ghost slides over and joins the thumb.
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.
- A change begins on pointerdown or the first arrow, Page or Home/End key, and records the start value; it ends on pointerup or 900ms after the last key. The span between start and value is striped when the value went up and outlined with a dashed border when it went down, and the label reads the difference with a plus or a true minus sign.
- Ending adds a settle class that turns on transitions and points the ghost, the bracket and the change span at the thumb, so they collapse into it and fade; after the transition the extras are removed.
Accessibility: A native range input with an aria-label and a dollar aria-valuetext; keyboard focus rings the thumb. Under prefers-reduced-motion the change fades out without sliding.
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
Grab the thumb and a ghost stays at the value you started from, with a bracket and a signed change between them; let go and the ghost slides over and joins the thumb.
A native range input with an aria-label and a dollar aria-valuetext; keyboard focus rings the thumb. Under prefers-reduced-motion the change fades out without sliding.