elastic slider.
A fader that stretches and bows when you drag it past either end, then springs back when you let go.
drag past either end
live component, try it
what to expect.
how it works.
Past either end the pull is resisted rather than ignored, and the overshoot is spent twice: the group stretches from the end that is not being pulled, and the track's control point leaves the centreline so the band bows under the thumb. The readout rides inside that stretch and would be resampled with it, so it carries the inverse scale. That sounds like the card that expands in place and is a different problem: there the delta is measured once and the inverse is known before anything moves, here the scale changes every frame under a live gesture, so the inverse has to come out of the same frame. The fill is the track's own curve cut at the thumb, because a second curve drawn near the first parts company with it the moment either bows.
the component.
import { rubberBand } from "../_shared/drag";
export function overshootFor(pointerX: number, width: number, resistance: number): number {
// Past either end the pull is resisted rather than ignored.
if (pointerX < 0) return rubberBand(pointerX, width, resistance);
if (pointerX > width) return rubberBand(pointerX - width, width, resistance);
return 0;
}
export function valueAt(
pointerX: number,
width: number,
min: number,
max: number,
step: number,
): number {
const t = Math.min(1, Math.max(0, width > 0 ? pointerX / width : 0));
const raw = min + t * (max - min);
return Math.min(max, Math.max(min, Math.round(raw / step) * step));
}
export interface Deformation {
scaleX: number;
scaleY: number;
originX: 0 | 1;
}
const SQUASH = 0.6;
export function deformation(overshoot: number, width: number, stretch: number): Deformation {
const scaleX = width > 0 ? 1 + (Math.abs(overshoot) / width) * stretch : 1;
return {
scaleX,
scaleY: 1 - (scaleX - 1) * SQUASH,
// Anchor the far end so the stretch follows the pointer.
originX: overshoot < 0 ? 1 : 0,
};
}
// Inverse scale for the label so text is not resampled mid-drag.
export function counter(d: { scaleX: number; scaleY: number }): { scaleX: number; scaleY: number } {
return { scaleX: 1 / d.scaleX, scaleY: 1 / d.scaleY };
}
// Solve the quadratic for t at a given x; x is not linear in t once the curve bows.
export function tAtX(x: number, cx: number, width: number): number {
const a = width - 2 * cx;
const b = 2 * cx;
if (Math.abs(a) < 1e-9) return b === 0 ? 0 : x / b;
const root = Math.sqrt(Math.max(0, b * b + 4 * a * x));
return (-b + root) / (2 * a);
}
const r = (n: number) => Math.round(n * 100) / 100;
export function trackPath(width: number, cy: number, cx: number, sag: number): string {
return `M 0 ${r(cy)} Q ${r(cx)} ${r(cy + sag)} ${r(width)} ${r(cy)}`;
}
export interface Point {
x: number;
y: number;
}
export function splitAt(
width: number,
cy: number,
cx: number,
sag: number,
thumbX: number,
): { control: Point; end: Point } {
const t = Math.min(1, Math.max(0, tAtX(thumbX, cx, width)));
const ctrlY = cy + sag;
// De Casteljau split: the fill is the track's own curve cut at the thumb.
const a = { x: cx * t, y: cy + (ctrlY - cy) * t };
const b = { x: cx + (width - cx) * t, y: ctrlY + (cy - ctrlY) * t };
return { control: a, end: { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t } };
}
export function fillPath(
width: number,
cy: number,
cx: number,
sag: number,
thumbX: number,
): string {
const { control, end } = splitAt(width, cy, cx, sag, thumbX);
return `M 0 ${r(cy)} Q ${r(control.x)} ${r(control.y)} ${r(end.x)} ${r(end.y)}`;
}