Часы работы пн-пт 9:00 — 18:00
Адрес г. Тула, ул. Некрасова, 7, оф. 315
Кастомный ползунок на JS и CSS: как сделать

Кастомный ползунок на JS и CSS: как сделать

Owebs
4 апреля 2025
Просмотры 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();

Живое демо: попробуйте

Итого: 5000
0 ₽
5000 ₽
0 ₽5000 ₽10000 ₽

Типичные ошибки при создании кастомного ползунка

Даже с готовым кодом легко допустить ошибки, которые ломают UX. Вот 4 самые частые проблемы и их решения:

Часто задаваемые вопросы

Почему вы отказались от использования стандартного , если он уже встроен в браузер?

17:37

Стандартный ползунок сложно стилизовать и адаптировать под сложные сценарии, например, с двумя бегунками. Наша реализация на div и JS даёт полный контроль над внешним видом, поведением и доступностью, что критически важно для современного UX.

17:49

Как сделать, чтобы ползунок корректно работал на тач-устройствах?

18:46

В коде нужно обрабатывать события touchstart, touchmove и touchend наравне с mousedown, mousemove и mouseup. Это обеспечит перетаскивание бегунков пальцем, а не только мышью, что обязательно для адаптивности.

19:14

Что делать, если шаг (step) не целое число, например, 0.5?

19:51

В текущей реализации шаг задаётся через , который поддерживает дробные значения. В JS нужно лишь округлять вычисленное значение до ближайшего кратного step, например, с помощью Math.round(value / step) * step.

20:00

Можно ли добавить третий бегунок, например, для среднего значения?

21:01

Да, это несложно: добавьте ещё один элемент .thumb в HTML, пропишите ему отдельный data-value и обновите JS-логику сортировки бегунков по позиции. Однако учтите, что это усложнит расчёт заполнения (range-fill) и проверку пересечений.

21:07

Как изменить цветовую тему ползунка без переписывания всего CSS?

21:42

Достаточно переопределить CSS-переменные в блоке :root, например, --primary и --bg. Это позволяет быстро адаптировать дизайн под корпоративные цвета или тёмную/светлую тему без изменения структуры стилей.

21:57
Ow
Имя:
Комментарий:
6 комментариев
Больше года назад

Здравствуйте! Сделал ползунок по вашему туториалу, но когда я навожу на него мышкой, он прыгает и тормозит. В чем может быть проблема?

Ответить
Посмотреть ответы Скрыть ответы
Имя:
Owebs
Больше года назад

Сергей Петров

Здравствуйте, Сергей! Скорее всего, дело в том, что вы используете JS для анимации hover-эффекта. Для реактивных эффектов, таких как hover, focus или click, лучше применять CSS transition — так анимация будет гораздо плавнее, потому что CSS-анимации не грузят процессор так, как JavaScript. Попробуйте переписать реакцию на наведение с помощью @keyframes или просто transition на свойстве background-color/transform.

Ответить
Имя:
Больше года назад

А подскажите, какой способ лучше для самого ползунка — через @keyframes анимацию или просто transition? У меня слайдер из 5 картинок, хочу сделать плавное перелистывание.

Ответить
Посмотреть ответы Скрыть ответы
Имя:
Owebs
Больше года назад

Елена

Елена, для плавного изменения стилей в слайдере советуем использовать CSS transition, а не @keyframes. Transition отлично подходит для простых переходов между двумя состояниями (например, при клике на кнопку). @keyframes же лучше оставить для сложных цикличных анимаций, где нужно несколько шагов. В вашем случае с 5 картинками transition даст более предсказуемую и производительную анимацию.

Ответить
Имя:
Больше года назад

Привет! Я хочу сделать не просто ползунок, а типа аккордеон с плавным открытием блока. Можно ли использовать ваш пример и как адаптировать?

Ответить
Посмотреть ответы Скрыть ответы
Имя:
Owebs
Больше года назад

Дмитрий Смирнов

Здравствуйте, Дмитрий! Конечно, можно. Аккордеон — это классический пример применения JS-анимаций, где вы отслеживаете click и динамически меняете высоту или opacity блока. Для плавности используйте CSS transition на свойствах max-height и opacity. Важно: если блок сворачивается, анимация через transition будет работать без рывков, а вот с @keyframes для аккордеона вы рискуете получить резкие скачки. Для ползунка как в статье — меняйте width ползунка через transition, а для аккордеона — высоту через аналогичный подход.

Ответить
Имя:
Развернуть все Скрыть

Популярные

Анимированный индикатор для пунктов меню на сайте
Просмотры 998
Рейтинг 15
8 декабря
7 месяцев назад

Расскажем как написать анимированный индикатор для пунктов меню на сайте. Так портал станет более интерактивным и привлекательным для пользователей.

Как оформить блок акции через CSS в 2025: Зачеркнутая цена + эффектная скидка за 5 минут
Просмотры 1797
Рейтинг 6
14 октября
8 месяцев назад

В данном уроке будем стилизовать текст для объявлений об акции. Необходимо сделать так, чтобы наше объявление привлекло потенциальных покупателей

Создание сайтов через нейросети: плюсы и минусы
Просмотры 1409
Рейтинг 4
9 февраля 2025
Больше года назад

Популярность нейросетей в 2025 году набирает обороты. Нет, они не заменяют полноценных специалистов. Зато существенно упрощают и ускоряют работу тех же копирайтеров, сеошников и дизайнеров.

Сегодня мы расскажем, какие нейросети можно использовать в работе, разберем их функционал и приведем примеры генерации.

Оставьте заявку

Поставив галочку, Вы даете согласие на обработку ваших Персональных данных

Услуги

Разработка сайта
  • Анализ ниши и конкурентов
  • Создание структуры и прототипа
  • Дизайн с адаптацией под мобильные
  • Верстка на чистом коде или CMS
  • Настройка форм обратной связи
  • Подключение аналитики и метрик
  • SEO-базовая оптимизация страниц
  • Тестирование и запуск на хостинге
Разработка корпоративного сайта
  • Сайт-визитка
  • Информационный портал
  • Промо-сайт
Корпоративный сайт
  • Создание SEO-стратегии
  • Анализ структуры сайта
  • Сбор семантического ядра
  • Наполнение контентом
Ошибка Проявление Как исправить в коде
Бегунки перекрывают друг друга Левый бегунок уходит правее правого, значения становятся отрицательными В методе drag() добавить проверку: if (isLeft && value >= this.getValue(this.right) - step) return; — это уже есть в нашем коде, но часто забывают
Заполнение (range-fill) выходит за края трека Синяя полоса обрезается или уходит за границы .range-track Убедиться, что left и width считаются в процентах от max, а не от текущего значения. Использовать Math.min/max