Coding soon

Coding soon

Slider

Effortless smooth values and ranges

Github

Single value#

const slide = new Slider("#slider", {
	val: 50, 
	min: 0, 
	max: 100, 
	col: colors(1)
});

Step#

const slide = new Slider("#slider", {
	val: 50, 
	max: 100, 
	stp: 10, 
	col: colors(1)
});

Multiple values#

const slide = new Slider("#slider", {
	max: 100, 
	val: [8, 16, 32, 64],
	col: colors(4),
});

Range#

// two values + one color = range
const slide = new Slider("#slider", {
	val: [25, 75],
	col: colors(1)
});

Min range#

// minimum range = 10
const slide = new Slider("#slider", {
	val: [25, 75],
	rng: 10, 
	col: colors(1)
});

Multiple ranges#

// n values + n / 2 colors = ranges
const slide = new Slider("#slider", {
	val: [5, 10, 20, 35, 50, 80],
	col: colors(3),
});

Linked ranges#

// n values + (n - 1) colors = linked ranges
const slide = new Slider("#slider", {
	val: [10, 50, 70],
	col: colors(2)
});
// 3x linked ranges
const slide = new Slider("#slider", {
	val: [10, 20, 50, 70],
	col: colors(3)
});

Formatting#

// time format function
const slide = new Slider("#slider", {
	max: 5400, // seconds
	val: 3600, // seconds
	fmt: s =>
		(s < 3600 ? [60, 1] : [3600, 60, 1])
		.map(x =>
			`0${~~(s / x) % 60}`.slice(-2))
		.join(":"),
	col: colors(1)
});

Float#

const slide = new Slider("#slider", {
	min: 0,
	max: 0.1,
	stp: 0.001,
	val: [0.016, 0.032, 0.064],
	col: colors(3)
});

Colors#

const colors = (num = 1) => [
	"ff595e", "ff924c", "ffca3a", 
	"c5ca30", "8ac926", "52a675", 
	"1982c4", "4267ac", "6a4c93"]
.sort(() => Math.random() - 0.5)
.slice(0, num)
.map(col => "#" + col);

Styling#

:root {
	--slideHeight: 36px; /* slider height */
	--trackHeight: 6px; /* slide track height */
	--focusHeight: 10px; /* sliding track height */
	--trackColor: #e5e7eb; /* track background color */
	--textColor: #6b7280; /* min & max labels color */
	--fontSize: 14px; /* labels font size */
	--animate: 0.2s ease; /* sliding state animation */
}
<div id="slider"></div>
<div id="slides"></div>
body {
	height: 100%;
	display: flex;
	flex-direction: column;
	justify-content: center;
	align-items: center;
}

.slider {
	width: 360px;
	max-width: 360px;
	max-width: 90%;
}

#slides {
	font-size: var(--fontSize);
	pointer-events: none;
}
const slides = document
.querySelector("#slides");

const disp = dat => 
	slides.innerHTML = [dat]
	.flat()
	.map(
		(val, idx) =>
			`<font color="${slide.colors[idx]}">${JSON.stringify(val)}</font>`
	)
	.join("&nbsp;&nbsp;");

slide
.on(
	"slide", 
	evt => 
		disp(evt.detail)
);

slide
.on(
	"change", 
	evt => 
		disp(evt.detail)
);

disp(slide.values);

/**
 * @force
 * @export
 * @class Slider :
 */
class Slider {

	/**
	 * @construct
	 * @param {HTMLElement|string} container :
	 * @param {!SliderOptions=} opts :
	 */
	constructor(container, opts = {}) {

		this.dec = String(opts.stp || "")
		.includes(".")
			? String(opts.stp)
			.split(".")[1].length
			: 0;

		opts = {
			min: 0,
			max: 100,
			stp: 1,
			num: 1,
			col: "#4A90E2",
			fmt: v => 
				v.toFixed(this.dec),
			val: null,
			rng: 0,
			...opts
		};

		this.track
			= this.ranges
			= this.valueBox
			= this.labelBox
			= this.minLabel
			= this.maxLabel
			= this.valueLabels
			= this._frameLoop
				= null;

		this._startVal = 0;
		this._startPos = 0;

		this._step = opts.stp;
		this._range = opts.max - opts.min;
		
		this._disp = opts.fmt;
		this._colors = [opts.col].flat();
		this._ranged = opts.rng;
		this._activeIdx = -1;
		this._isMoving = false;
		this._frameLoop = null;

		const values = opts.val
			? [opts.val].flat()
			: this._defaultValues(opts.num);

		this._num = values.length;
		this._vals = Array(this._num)
		.fill(0);
		this._targetVals = Array(this._num)
		.fill(0);
		this._min = opts.min;
		this._max = opts.max;

		this.wrap = container;

		if(typeof container === "string") 
			this.wrap = document
			.querySelector(container);

		this.wrap.classList.add("slider");
		this.wrap.tabIndex = 0;

		this._createElements();
		this._setupEvents();

		this.values = values;

		this._evts = new NativeEventTarget();
	
	}

	_createElements() {

		const c = cls =>
			Object.assign(
				document.createElement("div"),
				{
					className: cls 
				}
			);

		[
			this.track,
			this.valueBox,
			this.labelBox,
			this.minLabel,
			this.maxLabel
		] = ["track", "vals", "lbls", "", ""].map(c);

		this.ranges = Array(Math.max(
			this._num,
			this._colors.length
		))
		.fill(0)
		.map(() => 
			c("range"));

		this.valueLabels = Array(this._num)
		.fill(0)
		.map(() => 
			c("val"));

		this.valueBox.append(...this.valueLabels);
		this.wrap.append(this.valueBox);

		this.track.append(...this.ranges);
		this.wrap.append(this.track);

		this.labelBox.append(
			this.minLabel,
			this.maxLabel
		);
		this.wrap.append(this.labelBox);

		this.minLabel.innerText = this._disp(this._min);
		this.maxLabel.innerText = this._disp(this._max);
	
	}

	_setupEvents() {

		const move = evt => 
				this._activeIdx !== -1 && this._onMove(evt),
			up = evt => 
				this._activeIdx !== -1 && this._onUp(evt);

		this.wrap.addEventListener(
			"pointerdown",
			evt => 
				this._onDown(evt)
		);

		["pointermove", "pointerup", "pointercancel"].forEach(evt =>
			this.wrap.addEventListener(
				evt,
				evt === "pointermove" ? move : up
			));

		// this.wrap.addEventListener("keydown", evt => this._onKey(evt));
	
	}

	_defaultValues(n) {

		const stp = this._range / (n + 1);

		return Array(n)
		.fill(0)
		.map((_, i) => 
			this._roundToStep(this._min + stp * (i + 1)));
	
	}

	/**
	 * @export
	 * @getter
	 * @type {number|Array} values : slider values
	 */
	get values() {

		const vals = this._vals.map(v => 
			this._roundToStep(v));

		if(this._num === 1) 
			return vals[0];

		if(this._colors.length >= this._num) 
			return vals;

		if(this._colors.length === this._num - 1) {

			const sorted = [...vals].sort((a, b) => 
				a - b);

			return Array(sorted.length - 1)
			.fill(0)
			.map((_, i) => 
				[sorted[i], sorted[i + 1]]);
		
		}

		if(this._num % 2 === 0) {

			const ranges = [];

			for(let i = 0; i < vals.length; i += 2) {

				ranges.push([vals[i], vals[i + 1]].sort((a, b) => 
					a - b));
			
			}

			return ranges.length === 1 ? ranges[0] : ranges;
		
		}

		const sorted = [...vals].sort((a, b) => 
			a - b);

		return Array(sorted.length - 1)
		.fill(0)
		.map((_, i) => 
			[sorted[i], sorted[i + 1]]);
	
	}

	/**
	 * @setter
	 * @type {number|Array} values : slider values
	 */
	set values(v) {

		// IGNORE IF USER IS SLIDING ?

		const newVals = [v]
		.flat()
		.map(val => 
			(Array.isArray(val) ? val : [val]))
		.flat();

		if(newVals.length !== this._num) {

			throw new Error(this._num + " values expected");
		
		}

		this._vals = newVals.map(v => 
			this._forceRange(+v));
		this._targetVals = [...this._vals];
		this._updateUI();
	
	}

	/**
	 * @export
	 * @getter
	 * @type {Array} colors : slider colors
	 */
	get colors() {

		return this._colors;
	
	}

	/**
	 * @setter
	 * @type {Array} colors : slider colors
	 */
	set colors(c) {

		this._colors = c;

		this._updateUI();
	
	}

	_calculateLabelPositions(pos) {

		const rWidth = this.wrap.offsetWidth,
			minGap = 4;
		const labels = pos
		.map((p, i) => 
			({
				origPos: p,
				origPx: (p / 100) * rWidth,
				idx: i,
				fullWidth: this.valueLabels[i].offsetWidth,
				halfWidth: this.valueLabels[i].offsetWidth / 2
			}))
		.sort((a, b) => 
			a.origPx - b.origPx);

		const result = [...pos];
		const processEdge = (l, left) => {

			const edgePx = left ? l.halfWidth : rWidth - l.halfWidth,
				off = left ? 1 : -1;

			l.origPx = edgePx;
			result[l.idx] = (edgePx / rWidth) * 100;

			labels
			.filter(
				o =>
					o !== l
						&& Math.abs(o.origPx - l.origPx) < l.fullWidth + minGap
			)
			.forEach((n, i) => {

				n.origPx = edgePx + (i + 1) * (minGap + 2) * off;
				result[n.idx] = (n.origPx / rWidth) * 100;
			
			});
		
		};

		labels
		.filter(l => 
			l.origPx < l.halfWidth)
		.forEach(l => 
			processEdge(
				l,
				true
			));
		labels
		.filter(l => 
			l.origPx > rWidth - l.halfWidth)
		.forEach(l => 
			processEdge(
				l,
				false
			));

		for(let iter = 0, hasOverlap = true; hasOverlap && iter < 50; iter++) {

			hasOverlap = false;

			for(let i = 0; i < labels.length - 1; i++) {

				const [c, n] = [labels[i], labels[i + 1]],
					minSpace = (c.fullWidth + n.fullWidth) / 2 + minGap,
					space = n.origPx - c.origPx;

				if(space < minSpace) {

					hasOverlap = true;
					const adj = (minSpace - space) / 2,
						lAdj = Math.min(
							adj,
							c.origPx - c.halfWidth
						),
						rAdj = Math.min(
							adj,
							rWidth - n.halfWidth - n.origPx
						);

					c.origPx -= lAdj;
					n.origPx += rAdj;
					result[c.idx] = (c.origPx / rWidth) * 100;
					result[n.idx] = (n.origPx / rWidth) * 100;
				
				}
			
			}
		
		}

		return result;
	
	}

	_updateUI() {

		const pos = this._vals.map(
				v => 
					((v - this._min) / this._range) * 100
			),
			cLen = this._colors.length,
			sorted = this._vals
			.map((v, i) => 
				({
					v, i 
				}))
			.sort((a, b) => 
				b.v - a.v)
			.map(x => 
				x.i);

		if(cLen >= this._num) {

			pos.forEach((p, i) =>
				this._updateLine(
					this.ranges[i],
					0,
					p,
					this._colors[i],
					sorted.indexOf(i) + 1
				));
		
		}
		else if(cLen === this._num - 1) {

			const sVals = [...this._vals].sort((a, b) => 
				a - b);

			for(let i = 0; i < this._num - 1; i++) {

				const [p1, p2] = [
					((sVals[i] - this._min) / this._range) * 100,
					((sVals[i + 1] - this._min) / this._range) * 100
				];

				this._updateLine(
					this.ranges[i],
					p1,
					p2 - p1,
					this._colors[i],
					i + 1
				);
			
			}
		
		}
		else if(this._num % 2 === 0) {

			for(let i = 0; i < this._num; i += 2) {

				const [p1, p2] = [pos[i], pos[i + 1]],
					[l, r] = p1 < p2 ? [p1, p2] : [p2, p1];

				this._updateLine(
					this.ranges[i / 2],
					l,
					r - l,
					this._colors[(i / 2) % cLen],
					i / 2 + 1
				);
			
			}
		
		}
		else {

			const sVals = this._vals
			.map((v, i) => 
				({
					v, i 
				}))
			.sort((a, b) => 
				a.v - b.v);

			for(let i = 0; i < sVals.length - 1; i++) {

				const [p1, p2] = [pos[sVals[i].i], pos[sVals[i + 1].i]];

				this._updateLine(
					this.ranges[i],
					Math.min(
						p1,
						p2
					),
					Math.abs(p2 - p1),
					this._colors[i],
					i + 1
				);
			
			}
		
		}

		const adjPos = this._calculateLabelPositions(pos);

		this._vals.forEach((val, i) => {

			let cIdx;

			if(cLen >= this._num) {

				cIdx = i;
			
			}
			else if(cLen === this._num - 1) {

				const sorted = [...this._vals]
				.map((v, idx) => 
					({
						v, idx 
					}))
				.sort((a, b) => 
					a.v - b.v);
				const myPos = sorted.findIndex(x => 
					x.idx === i);

				cIdx = Math.min(
					myPos,
					cLen - 1
				);
			
			}
			else if(this._num % 2 === 0) {

				cIdx = Math.floor(i / 2) % cLen;
			
			}
			else {

				const sortedVals = [
					...new Set([...this._vals].sort((a, b) => 
						a - b))
				];

				cIdx = Math.min(
					sortedVals.indexOf(val),
					cLen - 1
				);
			
			}

			const label = this.valueLabels[i];

			label.innerText = this._disp(this._roundToStep(val));

			this._updateLabel(
				label,
				adjPos[i],
				this._colors[cIdx]
			);
		
		});
	
	}

	_updateLine(el, lft, wth, col, zdx) {

		Object.assign(
			el.style,
			{
				left: lft + "%",
				width: wth + "%",
				backgroundColor: col,
				zIndex: zdx
			}
		);
	
	}

	_updateLabel(el, lft, col) {

		const [rW, lW] = [this.wrap.offsetWidth, el.offsetWidth],
			pxPos = (lft / 100) * rW,
			edge = lW / 2;
		let tX = -50;

		if(pxPos < edge) 
			tX = -((pxPos / edge) * 50);
		else if(pxPos > rW - edge) 
			tX = -100 + ((rW - pxPos) / edge) * 50;

		Object.assign(
			el.style,
			{
				left: lft + "%",
				color: col,
				transform: `translateX(${tX}%)`
			}
		);
	
	}

	_roundToStep(v) {

		return this._forceRange(
			+parseFloat(this._step * Math.round(v / this._step))
			.toFixed(
				this.dec
			)
		);
	
	}

	_forceRange(v) {

		return Math.max(
			this._min,
			Math.min(
				this._max,
				v
			)
		);
	
	}

	_onDown(e) {

		const rect = this.wrap.getBoundingClientRect(),
			pos = (e.clientX - rect.left) / rect.width,
			val = this._min + pos * this._range;

		this._activeIdx = this._vals.reduce(
			(c, _, i) =>
				Math.abs(val - this._vals[i]) < Math.abs(val - this._vals[c])
					? i
					: c,
			0
		);
		[this._startVal, this._startPos] = [this._vals[this._activeIdx], pos];
		this._isMoving = false;
		this._slideState(true);
		this.wrap.setPointerCapture(e.pointerId);
	
	}

	_onMove(e) {

		const rect = this.wrap.getBoundingClientRect(),
			pos = (e.clientX - rect.left) / rect.width,
			delta = pos - this._startPos;
		let newVal = this._roundToStep(this._startVal + delta * this._range);

		if(
			this._ranged
			&& this._num % 2 === 0
			&& this._colors.length < this._num
		) {

			const pIdx
					= this._activeIdx % 2 === 0
						? this._activeIdx + 1
						: this._activeIdx - 1,
				pVal = this._vals[pIdx],
				isLower = this._activeIdx % 2 === 0;

			if(isLower ? newVal > pVal : newVal < pVal) {

				this._targetVals[pIdx] = newVal;
				this._targetVals[this._activeIdx] = pVal;
				[this._activeIdx, this._startVal, this._startPos] = [
					pIdx,
					pVal,
					pos
				];

				return this._animate();
			
			}

			newVal = isLower
				? Math.min(
					newVal,
					pVal - this._ranged
				)
				: Math.max(
					newVal,
					pVal + this._ranged
				);
		
		}

		if(!this._isMoving && newVal !== this._targetVals[this._activeIdx]) {

			this._isMoving = true;
			this._dispatch("start");
		
		}

		this._targetVals[this._activeIdx] = newVal;
		this._animate();
	
	}

	_onUp(e) {

		this.wrap.releasePointerCapture(e.pointerId);
		[this._activeIdx, this._isMoving] = [-1, false];
		this._slideState(false);
	
	}

	_onKey(evt) {

		// console.log("key", evt.key);

		/*

		handle multiple tabindex to select all cursors

		*/

		const keySteps = [
			["ArrowLeft", "ArrowDown"], 
			["ArrowRight", "ArrowUp"]
		];

		const idx = keySteps.findIndex(keys => 
			keys.includes(evt.key));

		if(idx != -1) {

			const dir = idx * 2 - 1;

			// NOT RESPECTING MIN RANGE
			this._targetVals[0] = this._forceRange(this._targetVals[0] + dir * this._step);
			// this._updateUI();
			// this._changes();
			this._animate();

		}

	}

	_animate() {

		if(this._frameLoop) 
			return;

		const step = () => {

			let [needsUpdate, valueChanged] = [false, false];
			const EASE = 0.2;
			const PRECISION = this._step / 10;

			this._vals.forEach((val, i) => {

				const target = this._targetVals[i];

				if(val !== target) {

					const delta = target - val;

					if(Math.abs(delta) < PRECISION) {

						this._vals[i] = target;
						valueChanged = true;
					
					}
					else {

						this._vals[i] = val + delta * EASE;
						needsUpdate = true;
					
					}
				
				}
			
			});

			if(needsUpdate || valueChanged) {

				this._updateUI();
				this._dispatch("slide");

				if(needsUpdate) {

					this._frameLoop = requestAnimationFrame(step);
				
				}
				else {

					this._frameLoop = null;
					this._changes();
					this._isMoving = false;
				
				}
			
			}
			else 
				this._frameLoop = null;
		
		};

		this._frameLoop = requestAnimationFrame(step);
	
	}

	_slideState(stt) {

		this.wrap.classList.toggle(
			"sliding",
			stt
		);
	
	}

	/**
	 * @force
	 * @export
	 * @method on :
	 * @param {string} evt :
	 * @param {Function} handler :
	 */
	on(evt, handler) {

		this._evts.addEventListener(
			evt,
			handler
		);
	
	}

	/**
	 * @force
	 * @export
	 * @method off :
	 * @param {string} evt :
	 * @param {Function} handler :
	 */
	off(evt, handler) {

		this._evts.removeEventListener(
			evt,
			handler
		);
	
	}

	_dispatch(type) {

		this._evts.dispatchEvent(
			new CustomEvent(
				type,
				{
					detail: this.values 
				}
			)
		);
	
	}

	_changes() {

		// this._aria();

		this._dispatch("change");

	}

	_aria() {

		this._sing(
			"min",
			this._min
		);
		this._sing(
			"max",
			this._max
		);

		// aria-valuenow="20.0"

	}

	_sing(fld, val) {

		this.wrap
		.setAttribute(
			"aria-value" + fld,
			val
		);

	}

	destroy() {

		this._frameLoop && cancelAnimationFrame(this._frameLoop);
		// off listeners
		this.wrap.remove();
	
	}

}

// closure compiler warning fix
var NativeEventTarget = self["EventTarget"];

:root {
	--slideHeight: 44px; /* slider height */
	--trackHeight: 6px; /* slide track height */
	--focusHeight: 10px; /* sliding track height */
	--trackColor: #e5e7eb; /* track background color */
	--textColor: #6b7280; /* min & max labels color */
	--fontSize: 14px; /* labels font size */
	--animate: 0.2s ease; /* sliding state animation */

	/* PRECALC LABELS OFFSETS */

	--textSpc: calc(var(--trackHeight) * 2 / 3);
	--textOff: calc((var(--fontSize) + var(--textSpc)) * -1);
	--slidSpc: calc(var(--focusHeight) * 2 / 3);
	--slidOff: calc((var(--fontSize) + var(--slidSpc)) * -1);
}

/* DARK THEME */

@media (prefers-color-scheme: dark) {
	:root {
		--trackColor: #363636;
		--textColor: #6b7280;
	}
}

/* SLIDER WRAP */

.slider {
	position: relative;
	touch-action: none;
	height: var(--slideHeight);
	color: var(--textColor);
	cursor: pointer;
	outline: none;
}

.slider div {
	user-select: none;
	-webkit-user-select: none;
}

/* TRACK & RANGES */

.slider .track {
	position: absolute;
	top: 50%;
	width: 100%;
	height: var(--trackHeight);
	transform: translateY(-50%);
	background: var(--trackColor);
	will-change: height;
	transition: height var(--animate);
}

.slider .range {
	position: absolute;
	height: 100%;
	will-change: left, width;
}

/* LABELS */

.slider .vals,
.slider .lbls {
	position: absolute;
	top: 50%;
	width: 100%;
	font-size: var(--fontSize);
	line-height: var(--fontSize);
	transition: transform var(--animate);
}

/* VALUES */

.slider .vals {
	transform: translateY(var(--textOff));
}

.slider .val {
	position: absolute;
	will-change: left, transform;
	white-space: nowrap;
}

/* MIN MAX */

.slider .lbls {
	display: flex;
	align-items: center;
	justify-content: space-between;
	transform: translateY(var(--textSpc));
}

/* SLIDING STATE */

.slider.sliding .track {
	height: var(--focusHeight);
}

.slider.sliding .vals {
	transform: translateY(var(--slidOff));
}

.slider.sliding .lbls {
	transform: translateY(var(--slidSpc));
}

Simple slider#

Just seeking ♪♬
Swipe or click

<div id="seektrack"></div>
body {
	height: 100%;
	display: flex;
	flex-direction: column;
	justify-content: center;
	align-items: center;
}

.seektrack {
	width: 360px;
	max-width: 360px;
	max-width: 90%;
}
const seekDatTrack = new SeekTrack(
	document.querySelector("#seektrack"), 
	v => console.log(v));

seekDatTrack.dur = 180;
seekDatTrack.val = 100;
Seek JS
+
/**
 * @force
 * @export
 * @class SeekTrack : seek that track
 */
class SeekTrack {

	/**
	 * @construct
	 * @param {HTMLElement} wrap :
	 * @param {Function} seek :
	 */
	constructor(wrap, seek) {

		[this._track, this._timed, this._texts, this._start, this._total] 
			= ["track", "timed", "texts", "start", "total"].map(cls => {

				const el = document.createElement("div");

				el.classList.add(cls);

				return el;
			
			});

		// Init state
		this._min 
		= this._max 
		= this._val 
		= this._aim 
		= this._startPos 
		= this._startVal 
		
		= 0;

		this._down 
		= this._moved 
		= this._anm 
		= false;

		this._lst
		= this._frm 
		= this._rect 
		= null;

		this._cbk = seek;

		this._wrap = wrap;
		this._wrap.className = "seektrack";
		
		this._track.append(this._timed);

		this._texts.append(
			this._start,
			this._total
		);

		this._wrap.append(
			this._texts,
			this._track
		);

		this._wrap["onpointerdown"] = e => {

			this._rect = this._wrap.getBoundingClientRect();
			this._down = true;
			this._moved = false;
			this._startPos = e.clientX - this._rect.left;
			this._startVal = this._val;
			this._wrap.setPointerCapture(e.pointerId);
		
		};
		
		this._wrap["onpointermove"] = e => {

			if(!this._down) 
				return;

			this._moved = true;
			
			const pos = (e.clientX - this._rect.left) / this._rect.width;
			const delta = pos - this._startPos / this._rect.width;

			this._aim = Math.max(
				this._min,
				Math.min(
					this._max, 
					Math.round(this._startVal + delta * this._max)
				)
			);
			this._animate();
		
		};
		
		this._wrap["onpointerup"] = this._wrap["onpointercancel"] = e => {

			if(!this._down) 
				return;

			this._down = false;
			this._wrap.releasePointerCapture(e.pointerId);
			
			if(!this._moved) {

				const clickPos = (e.clientX - this._rect.left) / this._rect.width;

				this._aim = Math.max(
					this._min,
					Math.min(
						this._max, 
						Math.round(clickPos * this._max)
					)
				);
				this._animate();
			
			}
		
		};

		this._updateUI();
	
	}

	_fmt(s) {

		return (s < 3600 ? [60, 1] : [3600, 60, 1])
		.map(x => 
			`0${~~(s / x) % 60}`.slice(-2))
		.join(":");
	
	}

	_animate() {

		if(this._frm) 
			cancelAnimationFrame(this._frm);

		this._anm = true;

		const step = () => {

			const delta = this._aim - this._val;

			if(Math.abs(delta) < 0.2) {

				this._val = this._aim;
				this._updateUI();
				this._frm = null;
				this._anm = false;

				if(this.val !== this._lst) {

					this._lst = this.val;
					this._cbk(this.val);
				
				}
			
			}
			else {

				this._val += delta * 0.2;
				this._updateUI();
				this._frame(step);
			
			}
		
		};

		this._frame(step);
	
	}

	_frame(cb) {

		this._frm = requestAnimationFrame(cb);
	
	}

	_updateUI() {

		this._timed.style.width = (this._val / this._max) * 100 + "%";
		this._start.textContent = this._fmt(this._val);
		this._total.textContent = this._fmt(this._max - this._val);
	
	}

	/**
	 * @force
	 * @export
	 * @getter
	 * @type {number} val : current time
	 */
	get val() {

		return Math.round(this._val);
	
	}

	/**
	 * @setter
	 */
	set val(v) {

		if(this._down || this._anm) 
			return;

		this._val = this._aim = Math.max(
			this._min,
			Math.min(
				this._max,
				+v
			)
		);

		this._updateUI();
	
	}

	/**
	 * @force
	 * @export
	 * @getter
	 * @type {number} dur : total value
	 */
	get dur() {

		return this._max;
	
	}

	/**
	 * @setter
	 */
	set dur(v) {

		this._max = v;

		this._updateUI();
	
	}

	destroy() {

		if(this._frm) 
			cancelAnimationFrame(this._frm);

		this._wrap.remove();
	
	}

}
Seek CSS
+
.seektrack {
	position: relative;
	width: 100%;
	height: 100%;
	font-size: 13px;
	color: #6b7280;
	cursor: pointer;
	touch-action: none;
	user-select: none;
}

.seektrack .track {
	position: relative;
	width: 100%;
	height: 6px;
	background: #c8c8c8;
}

.seektrack .timed {
	width: 0%;
	height: 100%;
	will-change: width;
	background: #4A90E2;
}

.seektrack .texts {
	width: 100%;
	display: flex;
	justify-content: space-between;
	pointer-events: none;
	padding-bottom: .3rem;
}

2K JS + 0.4K CSS

Seek JS min
+
/**
 * Nico Pr — https://nicopr.fr
 * SEEKTRACK STANDALONE RELEASE v0.1.0
 * 27/04/2025 01:18:32
 */
(function(){
function c(a){a.o&&cancelAnimationFrame(a.o);a.s=!0;const e=()=>{const b=a.l-a.g;.2>Math.abs(b)?(a.g=a.l,d(a),a.o=null,a.s=!1,a.val!==a.H&&(a.H=a.val,a.J(a.val))):(a.g+=.2*b,d(a),a.o=requestAnimationFrame(e))};a.o=requestAnimationFrame(e)}function d(a){a.D.style.width=a.g/a.h*100+"%";a.I.textContent=f(a.g);a.F.textContent=f(a.h-a.g)}function f(a){return(3600>a?[60,1]:[3600,60,1]).map(e=>`0${~~(a/e)%60}`.slice(-2)).join(":")}
class g{constructor(a,e){[this.G,this.D,this.C,this.I,this.F]=["track","timed","texts","start","total"].map(b=>{const l=document.createElement("div");l.classList.add(b);return l});this.u=this.h=this.g=this.l=this.A=this.B=0;this.m=this.v=this.s=!1;this.H=this.o=this.j=null;this.J=e;this.i=a;this.i.className="seektrack";this.G.append(this.D);this.C.append(this.I,this.F);this.i.append(this.C,this.G);this.i.onpointerdown=b=>{this.j=this.i.getBoundingClientRect();this.m=!0;this.v=!1;this.A=b.clientX-
this.j.left;this.B=this.g;this.i.setPointerCapture(b.pointerId)};this.i.onpointermove=b=>{this.m&&(this.v=!0,this.l=Math.max(this.u,Math.min(this.h,Math.round(this.B+((b.clientX-this.j.left)/this.j.width-this.A/this.j.width)*this.h))),c(this))};this.i.onpointerup=this.i.onpointercancel=b=>{this.m&&(this.m=!1,this.i.releasePointerCapture(b.pointerId),this.v||(this.l=Math.max(this.u,Math.min(this.h,Math.round((b.clientX-this.j.left)/this.j.width*this.h))),c(this)))};d(this)}get val(){return Math.round(this.g)}set val(a){this.m||
this.s||(this.g=this.l=Math.max(this.u,Math.min(this.h,+a)),d(this))}get dur(){return this.h}set dur(a){this.h=a;d(this)}}var h=g,k=["SeekTrack"],m=this||self;k[0]in m||"undefined"==typeof m.execScript||m.execScript("var "+k[0]);for(var n;k.length&&(n=k.shift());)k.length||void 0===h?m[n]&&m[n]!==Object.prototype[n]?m=m[n]:m=m[n]={}:m[n]=h;}).call(this);
Seek CSS min
+
.seektrack{position:relative;width:100%;height:100%;font-size:13px;color:#6b7280;cursor:pointer;touch-action:none;user-select:none}.seektrack .track{position:relative;width:100%;height:6px;background:#c8c8c8}.seektrack .timed{width:0%;height:100%;will-change:width;background:#4a90e2}.seektrack .texts{width:100%;display:flex;justify-content:space-between;pointer-events:none;padding-bottom:.3rem}