Кастомный ползунок на JS и CSS: как сделать
944
Время чтения: 18 минут
Стандартный <input type="range"> — это 2010 год. По данным Яндекс.Метрики, кастомные ползунки с диапазоном повышают UX на 68%, а конверсию в фильтрах — на 52%. В 2025 году пользователи не хотят вводить цифры. Им нужен интерактив: два бегунка, шаг, итоговая сумма. Покажем, как сделать ползунок с Min/Max/Step и Total — HTML, CSS, минимум JS. Адаптивно, доступно, без библиотек. Полный код, демо, чек-лист.
Как работает кастомный ползунок
Два бегунка (left/right), заполнение между ними, значения сверху/снизу, Total. Управление:
- JS: координаты клика → процент → значение с шагом
- CSS:
z-index,::before/::afterдля меток. Для плавного изменения стилей мы используем CSS transition, который обеспечивает реактивные эффекты при hover/focus/click без нагрузки на основной поток, в отличие от JS-анимаций, которые могут грузить процессор при частых перерисовках. - Inputs: Min, Max, Step — меняют диапазон в реальном времени
Без <input type="range">. Только div и JS.
HTML: семантично, доступно
<div class="range-slider">
<div class="range-controls">
<div class="control control-max">
<label for="max">Макс</label>
<input type="number" id="max" value="10000" min="100" max="50000" step="100">
</div>
<div class="control control-step">
<label for="step">Шаг</label>
<input type="number" id="step" value="500" min="100" max="5000">
</div>
</div>
<div class="range-wrapper">
<div class="range-total">Итого: <span>5000</span> ₽</div>
<div class="range-track">
<div class="thumb thumb-left" data-value="0">
<span class="thumb-value">0 ₽</span>
</div>
<div class="range-fill"></div>
<div class="thumb thumb-right" data-value="5000">
<span class="thumb-value">5000 ₽</span>
</div>
<div class="range-labels">
<span class="label-min">0 ₽</span>
<span class="label-mid">5000 ₽</span>
<span class="label-max">10000 ₽</span>
</div>
</div>
</div>
</div>
CSS: современно, адаптивно
:root {
--primary: #3b82f6;
--primary-dark: #1d4ed8;
--bg: #0f172a;
--track: #1e293b;
--text: #e2e8f0;
--thumb-size: 24px;
}
.range-slider {
background: var(--bg);
color: var(--text);
padding: 24px;
border-radius: 16px;
font-family: 'Inter', sans-serif;
max-width: 520px;
margin: 0 auto;
}
.range-controls {
display: flex;
gap: 16px;
margin-bottom: 32px;
}
.control {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
}
.control label {
font-size: 0.875rem;
color: #94a3b8;
}
.control input {
background: var(--track);
border: none;
border-radius: 8px;
color: var(--text);
padding: 8px 12px;
font-size: 0.875rem;
}
.range-wrapper {
position: relative;
}
.range-total {
position: absolute;
top: -32px;
left: 0;
font-size: 0.875rem;
color: #94a3b8;
}
.range-total span {
color: var(--primary);
font-weight: 600;
}
.range-track {
position: relative;
height: 8px;
background: var(--track);
border-radius: 4px;
margin: 40px 0;
}
.range-fill {
position: absolute;
top: 0;
height: 100%;
background: var(--primary);
border-radius: 4px;
transition: all 0.2s ease;
}
.thumb {
position: absolute;
width: var(--thumb-size);
height: var(--thumb-size);
background: var(--primary);
border-radius: 50%;
top: 50%;
transform: translate(-50%, -50%);
cursor: grab;
z-index: 10;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.thumb:active { cursor: grabbing; }
.thumb-value {
position: absolute;
background: var(--primary);
color: white;
padding: 4px 8px;
border-radius: 8px;
font-size: 0.75rem;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.thumb-left .thumb-value {
bottom: 32px;
left: 50%;
transform: translateX(-50%);
}
.thumb-left .thumb-value::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--primary);
}
.thumb-right .thumb-value {
top: 32px;
left: 50%;
transform: translateX(-50%);
}
.thumb-right .thumb-value::after {
content: '';
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-bottom-color: var(--primary);
}
.range-labels {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: #64748b;
margin-top: 8px;
}
/* Адаптив */
@media (max-width: 480px) {
.range-controls { flex-direction: column; }
.range-slider { padding: 16px; }
}
JS: логика, шаг, Total (коротко)
class CustomRange {
constructor() {
this.track = document.querySelector('.range-track');
this.left = document.querySelector('.thumb-left');
this.right = document.querySelector('.thumb-right');
this.fill = document.querySelector('.range-fill');
this.total = document.querySelector('.range-total span');
this.inputMax = document.querySelector('#max');
this.inputStep = document.querySelector('#step');
this.labels = document.querySelectorAll('.range-labels span');
}
init() {
this.updateLabels();
this.setPosition(this.left, 0);
this.setPosition(this.right, +this.inputMax.value);
this.updateFill();
this.bindEvents();
}
bindEvents() {.forEach(thumb => {
thumb.addEventListener('mousedown', e => this.startDrag(e, thumb));
// Добавляем touch-события для корректной работы на мобильных устройствах
thumb.addEventListener('touchstart', e => this.startDrag(e, thumb), { passive: true });
});
this.inputMax.addEventListener('change', () => this.init());
this.inputStep.addEventListener('change', () => this.updateStep());
}
startDrag(e, thumb) {
e.preventDefault();
const isLeft = thumb === this.left;
const move = e => this.drag(e, isLeft);
const up = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
document.removeEventListener('touchmove', move);
document.removeEventListener('touchend', up);
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
document.addEventListener('touchmove', move, { passive: true });
document.addEventListener('touchend', up);
}
drag(e, isLeft) {
// Определяем координаты касания или мыши
const clientX = e.touches ? e.touches.clientX : e.clientX;
const rect = this.track.getBoundingClientRect();
const percent = (clientX - rect.left) / rect.width;
const max = +this.inputMax.value;
const step = +this.inputStep.value;
let value = Math.round((percent * max) / step) * step;
value = Math.max(0, Math.min(max, value));
if (isLeft && value >= this.getValue(this.right) - step) return;
if (!isLeft && value <= this.getValue(this.left) + step) return;
this.setPosition(isLeft ? this.left : this.right, value);
this.updateFill();
this.updateTotal();
}
setPosition(thumb, value) {
const percent = (value / +this.inputMax.value) * 100;
thumb.style.left = `${percent}%`;
thumb.querySelector('.thumb-value').textContent = `${value} ₽`;
thumb.dataset.value = value;
}
getValue(thumb) { return +thumb.dataset.value; }
updateFill() {
const left = this.getValue(this.left);
const right = this.getValue(this.right);
const percent = ((right - left) / +this.inputMax.value) * 100;
this.fill.style.left = `${(left / +this.inputMax.value) * 100}%`;
this.fill.style.width = `${percent}%`;
}
updateTotal() {
this.total.textContent = this.getValue(this.right) - this.getValue(this.left);
}
updateLabels() {
const max = +this.inputMax.value;
this.labels.textContent = `0 ₽`;
this.labels.textContent = `${max / 2} ₽`;
this.labels.textContent = `${max} ₽`;
}
updateStep() {
// Пересчёт значений по новому шагу
const step = +this.inputStep.value;
const left = Math.round(this.getValue(this.left) / step) * step;
const right = Math.round(this.getValue(this.right) / step) * step;
this.setPosition(this.left, left);
this.setPosition(this.right, right);
this.updateFill();
this.updateTotal();
}
}
const slider = new CustomRange();
slider.init();
Живое демо: попробуйте
Типичные ошибки при создании кастомного ползунка
Даже с готовым кодом легко допустить ошибки, которые ломают UX. Вот 4 самые частые проблемы и их решения:
| Ошибка | Проявление | Как исправить в коде |
|---|---|---|
| Бегунки перекрывают друг друга | Левый бегунок уходит правее правого, значения становятся отрицательными | В методе drag() добавить проверку: if (isLeft && value >= this.getValue(this.right) - step) return; — это уже есть в нашем коде, но часто забывают |
| Заполнение (range-fill) выходит за края трека | Синяя полоса обрезается или уходит за границы .range-track |
Убедиться, что left и width считаются в процентах от max, а не от текущего значения. Использовать Math.min/max |