Coding soon

Coding soon

Slider

Effortless smooth values and ranges sliders

Github

Single value#

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

Step#

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

Multiple values#

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

Range#

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

Min range#

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

Multiple ranges#

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

Linked ranges#

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

Formatting#

// time format function
const slide = new Slider("#slider", {
	max: 5400, // 1 hour 30 minutes
	val: 3600, // 1 hour
	col: colors(1), 
	fmt: s => // format function
		(s < 3600 ? [60, 1] : [3600, 60, 1])
		.map(x => `0${~~(s / x) % 60}`.slice(-2))
		.join(":")
});
// date format function
const slide = new Slider("#slider", {
	min: new Date( // January 1st
		new Date().getFullYear(), 0, 1
	).getTime(),
	max: new Date( // December 31
		new Date().getFullYear(), 11, 31
	).getTime(),
	stp: 86400000, // 1 day step
	rng: 86400000, // 1 day min range
	col: colors(), // range color
	val: [ // initial range = [January 1st, now]
		new Date(new Date().getFullYear(), 0, 1), 
		Date.now()
	],
	fmt: s => // format function
		new Date(s).toLocaleDateString()
});

Float#

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

Alphabet#

const slide = new Slider("#slider", {
	min: 65,
	max: 90,
	rng: 1,
	col: colors(),
	val: [65, 70],
	fmt: s =>
		String.fromCharCode(s)
});

Vertical#

<div id="vals"></div>
<div id="range"></div>
<div id="links"></div>
<div class="slides"></div>
body {
	display: flex;
	justify-content: space-evenly;
	align-items: center;
}

.slider {
	line-height: 1;
}

.slider.vert {
    height: 240px;
}

.slides {
	display: none;
}
const vals = new Slider("#vals", {
	max: 64,
	val: [8, 16, 32],
	col: colors(3),
	dir: 1
});

const range = new Slider("#range", {
	max: 64,
	val: [16, 32],
	col: colors(),
	dir: 1
});

const links = new Slider("#links", {
	min: 1,
	max: 9,
	stp: 1,
	val: [2, 4, 6],
	col: colors(2),
	dir: 1
});

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 {
	--slideSize: 36px; /* slider size */
	--trackSize: 6px; /* slide track size */
	--focusSize: 10px; /* sliding track size */
	--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 class="slides"></div>
body {
	display: flex;
	flex-direction: column;
	justify-content: center;
	align-items: center;
}

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

.slider.vert {
    height: 240px;
}

.slides {
	font-size: var(--fontSize);
	user-select: none;
	-webkit-user-select: none;
	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 = {}) {

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

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

		this.dec = String(opts.stp)
		.includes(".") ? String(opts.stp)
			.split(".")[1].length : 0;
		this.wrap = typeof container === "string" ? document.querySelector(container) : container;

		this._dir = opts.dir;
		this._step = opts.stp;
		this._range = opts.max - opts.min;
		this._disp = opts.fmt;
		this._colors = [opts.col].flat();
		this._ranged = opts.rng;
		this._min = opts.min;
		this._max = opts.max;
		
		this._activePointers = new Map();
		this._isMoving = false;
		this._frameLoop = null;

		const values = opts.val !== null ? [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._evts = new NativeEventTarget();

		this._createElements();
		this._setupEvents();
		this.values = values;
	
	}

	_createElements() {

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

		if(this._dir)
			this.wrap.classList.add("vert");

		// this.wrap.tabIndex = 0;

		this.track = c("track");
		this.valueBox = c("vals");
		this.labelBox = c("lbls");
		this.minLabel = c();
		this.maxLabel = 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.track.append(...this.ranges);
		this.labelBox.append(
			this.minLabel,
			this.maxLabel
		);
		this.wrap.append(
			this.valueBox,
			this.track,
			this.labelBox
		);

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

	_setupEvents() {

		this.wrap.addEventListener(
			"pointerdown",
			e =>
				this._onDown(e)
		);
		this.wrap.addEventListener(
			"pointermove",
			e =>
				this._onMove(e)
		);
		this.wrap.addEventListener(
			"pointerup",
			e =>
				this._onUp(e)
		);
		this.wrap.addEventListener(
			"pointercancel",
			e =>
				this._onUp(e)
		);
	
	}

	_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) {

		const newVals = [v].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].flat();
		this._updateUI();

	}

	_calculateLabelPositions(pos) {

		const rSize = this._dim(this.wrap);

		if(rSize === 0)
			return pos;

		const minGap = 4;

		// 1. Create label data objects and sort by initial position
		let labels = pos.map((p, i) => {

			const labelEl = this.valueLabels[i];
			const fullSize = this._dim(labelEl);

			return {
				idx: i,
				px: (p / 100) * rSize,
				fullSize: fullSize,
				halfSize: fullSize / 2
			};
		
		})
		.sort((a, b) =>
			a.px - b.px);

		if(labels.length === 0)
			return pos;

		// 2. Iteratively resolve overlaps by simulating repulsive forces
		for(let iter = 0; iter < 100; iter++) {

			let forces = Array(labels.length)
			.fill(0);
			let systemStable = true;

			// Calculate repulsive forces between overlapping labels
			for(let i = 0; i < labels.length - 1; i++) {

				const l1 = labels[i];
				const l2 = labels[i + 1];
				const requiredDist = l1.halfSize + l2.halfSize + minGap;
				const actualDist = l2.px - l1.px;

				if(actualDist < requiredDist) {

					systemStable = false;
					const overlap = requiredDist - actualDist;

					forces[i] -= overlap / 2;
					forces[i + 1] += overlap / 2;
				
				}
			
			}

			// Calculate boundary forces to keep labels inside the track
			const first = labels[0];

			if(first.px < first.halfSize) {

				systemStable = false;
				forces[0] += (first.halfSize - first.px);
			
			}

			const last = labels[labels.length - 1];

			if(last.px > rSize - last.halfSize) {

				systemStable = false;
				forces[labels.length - 1] -= (last.px - (rSize - last.halfSize));
			
			}

			if(systemStable)
				break;

			// Apply forces to update label positions
			labels.forEach((label, i) => {

				label.px += forces[i];
			
			});
		
		}

		// 3. Create the final result array with percentage values
		const result = Array(pos.length);

		labels.forEach(l => {

			result[l.idx] = (l.px / rSize) * 100;
		
		});

		return result;
	
	}

	_updateUI() {

		const pos = this._vals.map(v =>
			((v - this._min) / this._range) * 100);
		const cLen = this._colors.length;

		if(this._num % 2 === 0 && cLen < this._num && cLen !== this._num - 1) { // ranges

			const activeRangeIndices = new Set(Array.from(
				this._activePointers.values(),
				p =>
					Math.floor(p.activeIdx / 2)
			));
			const rangeData = [];

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

				const oidx = i / 2;

				rangeData.push({
					start: Math.min(
						pos[i],
						pos[i + 1]
					),
					end: Math.max(
						pos[i],
						pos[i + 1]
					),
					width: Math.abs(pos[i] - pos[i + 1]),
					color: this._colors[oidx % cLen],
					oidx,
					actv: activeRangeIndices.has(oidx)
				});
			
			}

			const zOrder = [...rangeData].sort((a, b) => {

				if(a.actv !== b.actv)
					return a.actv ? 1 : -1;

				return b.width - a.width;
			
			})
			.map(d =>
				d.oidx);

			rangeData.forEach(def => {

				const zIndex = zOrder.indexOf(def.oidx) + 1;

				this._updateLine(
					this.ranges[def.oidx],
					def.start,
					def.width,
					def.color,
					zIndex
				);
			
			});
		
		}
		else { // single | multiple values

			const sortedForZ = 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],
						sortedForZ.indexOf(i) + 1
					));
			
			}
			else { // linked ranges

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

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

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

					this._updateLine(
						this.ranges[i],
						p1,
						p2 - p1,
						this._colors[i % cLen],
						i + 1
					);
				
				}
			
			}
		
		}

		const adjPos = this._calculateLabelPositions(pos);

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

			let cIdx;

			if(cLen >= this._num)
				cIdx = i;
			else if(this._num % 2 === 0 && cLen !== this._num - 1)
				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, start, size, col, zdx) {

		Object.assign(
			el.style,
			{
				backgroundColor: col,
				zIndex: zdx
			}
		);

		if(this._dir) { // vert

			el.style.top = (100 - start - size) + "%";
			el.style.height = size + "%";
		
		}
		else {

			el.style.left = start + "%";
			el.style.width = size + "%";
		
		}
	
	}

	_updateLabel(el, lft, col) {

		const rSize = this._dim(this.wrap);
		const lSize = this._dim(el);
		const pxPos = lft / 100 * rSize;
		const edge = lSize / 2;
		let t = -50;

		if(pxPos < edge)
			t = -(pxPos / edge * 50);
		else if(pxPos > rSize - edge)
			t = -100 + (rSize - pxPos) / edge * 50;
		
		el.style.color = col;
		/*Object.assign(
			el.style,
			{
				color: col
			}
		);*/

		if(this._dir) { // vert

			el.style.top = (100 - lft) + "%";
			el.style.transform = `translateY(${t}%)`;
		
		}
		else {

			el.style.left = lft + "%";
			el.style.transform = `translateX(${t}%)`;
		
		}
	
	}

	_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) {

		if(!!e.button)
			return;
		
		const pos = this._pos(e);
		const val = this._min + pos * this._range;

		const usedIndices = Array.from(
			this._activePointers.values(),
			p =>
				p.activeIdx
		);
		let closestIdx = -1, minDiff = Infinity;

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

			if(usedIndices.includes(i))
				return;

			const diff = Math.abs(val - v);

			if(diff < minDiff) {

				minDiff = diff;
				closestIdx = i;

			}
		
		});

		if(closestIdx !== -1) {

			const pid = e.pointerId;

			this.wrap.setPointerCapture(pid);
			this._activePointers.set(
				pid,
				{
					activeIdx: closestIdx,
					startVal: this._vals[closestIdx],
					startPos: pos
				}
			);

			this._slideState(true);
			this._updateUI(); // update zindex on down
		
		}
	
	}

	_onMove(e) {

		const pointer = this._activePointers.get(e.pointerId);

		if(!pointer)
			return;

		const pos = this._pos(e);
		const delta = pos - pointer.startPos;
		let newVal = this._roundToStep(pointer.startVal + delta * this._range);

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

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

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

				this._targetVals[pIdx] = newVal;
				this._targetVals[pointer.activeIdx] = pVal;
				[pointer.activeIdx, pointer.startVal, pointer.startPos] = [pIdx, pVal, pos];

				return this._animate();
			
			}

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

		if(!this._isMoving) {

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

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

	_onUp(e) {

		const pid = e.pointerId;

		if(!this._activePointers.has(pid))
			return;

		this.wrap.releasePointerCapture(pid);
		this._activePointers.delete(pid);

		if(this._activePointers.size === 0) {

			this._isMoving = false;
			this._slideState(false);
			this._updateUI(); // reset zindex
		
		}
	
	}

	_pos(evt) {

		const rect = this.wrap.getBoundingClientRect();

		return this._dir ? 1 - (evt.clientY - rect.top) / rect.height : (evt.clientX - rect.left) / rect.width;
	
	}

	_dim(elt) {

		return this._dir ? elt.offsetHeight : elt.offsetWidth;
	
	}

	_animate() {

		if(this._frameLoop)
			return;

		const step = () => {

			let needsUpdate = false,
				valueChanged = false;

			const EASE = 0.2,
				PRECISION = this._step / 10;

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

				const delta = this._targetVals[i] - val;

				if(Math.abs(delta) > 0) {

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

						this._vals[i] = this._targetVals[i];
						valueChanged = true;

					}
					else {

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

					}
				
				}
			
			});

			if(needsUpdate || valueChanged) {

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

				if(needsUpdate)
					this._frameLoop = requestAnimationFrame(step);
				else {

					this._frameLoop = null;
					this._dispatch("change");

				}
			
			}
			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
		);

		return this;

	}

	/**
	 * @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
			}
		));

	}
	
	destroy() {

		if(this._frameLoop)
			cancelAnimationFrame(this._frameLoop);

		this.wrap.remove();
	
	}

}

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

:root {
	--slideSize: 36px;
	--trackSize: 6px;
	--focusSize: 10px;
	--trackColor: #e5e7eb;
	--textColor: #6b7280;
	--fontSize: 14px;
	--animate: 0.2s ease;

	--textSpc: calc(var(--trackSize) * 1);
	--textOff: calc((var(--fontSize) + var(--textSpc)) * -1);
	--slidSpc: calc(var(--focusSize) * 3 / 4);
	--slidOff: calc((var(--fontSize) + var(--slidSpc)) * -1);
}

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

.slider {
	position: relative;
	height: var(--slideSize);
	color: var(--textColor);
	cursor: pointer;
	-webkit-tap-highlight-color: transparent;
	touch-action: none;
	user-select: none;
	outline: none;
}

.slider.sliding {
	/* cursor: grabbing; */
}

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

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

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

.slider .vals,
.slider .lbls {
	position: absolute;
	width: 100%;
	font-size: var(--fontSize);
	pointer-events: none;
	transition: transform var(--animate);
}

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

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

.slider .lbls {
	top: 50%;
	display: flex;
	justify-content: space-between;
	transform: translateY(var(--textSpc));
}

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

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

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

/* vertical */
.slider.vert {
	width: var(--slideSize);
}

.slider.vert .track {
	top: 0;
	left: 50%;
	width: var(--trackSize);
	height: 100%;
	transform: translateX(-50%);
	will-change: width;
	transition: width var(--animate);
}

.slider.vert .range {
	width: 100%;
	will-change: top, height;
}

.slider.vert .vals,
.slider.vert .lbls {
	top: 0;
	width: auto;
	height: 100%;
	/* line-height: .8rem; */
	line-height: var(--fontSize);
}

.slider.vert .vals {
	right: 50%;
	transform: translate(calc(var(--textSpc) * -1), 0);
}

.slider.vert .val {
	right: -100%;
	will-change: top, transform;
}

.slider.vert .lbls {
	left: 50%;
	flex-direction: column-reverse;
	transform: translateX(var(--textSpc));
}

.slider.vert.sliding .track {
	width: var(--focusSize);
}

.slider.vert.sliding .vals {
	transform: translateX(calc(var(--focusSize) * -1));
}

.slider.vert.sliding .lbls {
	transform: translateX(var(--focusSize));
}

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}