Веб

Snap переключение слайдов в слайдере при drag: CSS/JS

Реализуйте резкое (snap) переключение слайдов в слайдере при перетаскивании мышью или касанием. Подходы: CSS scroll-snap, JS с pointer-событиями, порог срабатывания, отключение transition. Примеры кода HTML/CSS/JS.

Как реализовать резкое (snap) переключение слайдов в слайдере при перетаскивании мышью или касании вместо плавного следования карусели за курсором (translate)? Какие подходы использовать: какие события (pointer/touch/mouse), как вычислить порог срабатывания и как отключить плавную анимацию при перетаскивании? Примеры кода (CSS/JS) приветствуются.

Чтобы получить резкое (snap) переключение слайдов при перетаскивании мышью или касании, используйте либо нативный CSS scroll-snap, либо ручную реализацию на JavaScript с pointer‑событиями и управлением transform. В JS‑варианте отключайте CSS‑переходы во время перетаскивания (например, класс .is-dragging → transition: none), вычисляйте порог как долю ширины слайда (обычно 20–30%) и/или учитывайте скорость (velocity), а при отпускании включайте transition для финального snap и корректируйте индекс слайда.


Содержание


Краткий обзор подходов

Есть два основных пути:

  • Нативный CSS scroll-snap: быстро, минимум кода, браузер сам делает «прилипание» к слайдам после прокрутки (подходит для простых галерей). См. практическое описание на Doka Guide.
  • Ручной JS‑контроль через translate + pointer‑события: даёт полный контроль над порогом, скоростью, моментом срабатывания и анимацией. Для гибкой логики drag/snap полезны идеи из статей на Habr и пошаговые примеры на WebForMySelf.

Выбор зависит от требований: нужна тонкая логика — берём JS; достаточно стандартного snap — CSS‑scroll-snap экономит время.


CSS scroll-snap: нативный snap при перетаскивании

Если вам подходит поведение «потянул — браузер прилипает к ближайшему слайду», можно сделать так (минимум JS или без него):

html
<!-- HTML -->
<div class="carousel">
 <div class="slide">Слайд 1</div>
 <div class="slide">Слайд 2</div>
 <div class="slide">Слайд 3</div>
</div>
css
.carousel {
 display: flex;
 overflow-x: auto;
 scroll-snap-type: x mandatory;
 -webkit-overflow-scrolling: touch; /* плавность на iOS */
 touch-action: pan-y; /* позволяет вертикальную прокрутку страницы */
 scroll-behavior: smooth; /* если вы хотите плавный snap при программном скролле */
}
.slide {
 flex: 0 0 100%; /* каждый слайд — ширина окна carousel */
 scroll-snap-align: start; /* или center */
 user-select: none;
 -webkit-user-drag: none;
}

Плюсы: простота, аппаратная оптимизация. Минусы: вы не управляете порогом срабатывания и инерцией детально; для сложных UX нужен JS. Подробности — Doka Guide.


JS: ручной drag с translate и резким snap (рекомендуемый)

Идея: во время перетаскивания обновляем transform без перехода (transition: none) — чтобы элемент следовал точно (без «плавного подтягивания»), а при отпускании вычисляем, переключаться ли на соседний слайд и включаем transition для финального snap.

Ключевые шаги:

  • Слушаем pointerdown → сохраняем startX и время; добавляем класс .is-dragging.
  • На pointermove вычисляем deltaX и делаем transform: translateX(...) через requestAnimationFrame.
  • На pointeruppointercancel) считаем пройденное расстояние и скорость, решаем по порогу/velocity новый индекс, включаем transition и ставим translateX(-index * width).
  • Управление transition: .is-dragging .track { transition: none; } — выключили плавную анимацию при движении; даём её при snap.

Примерная логика взята и адаптирована из материалов на Habr и WebForMySelf.


События: pointer vs touch vs mouse

Лучше всего использовать Pointer Events (pointerdown, pointermove, pointerup, pointercancel):

  • Они унифицируют мышь, касание и стилус.
  • Позволяют setPointerCapture/releasePointerCapture — не потеряете указатель при выходе за пределы элемента.
  • Поддерживаются в современных браузерах; при необходимости — fallback на touch/mouse.

Если Pointer Events не подходят, fallback:

  • touch: touchstart/touchmove/touchend (но нужно учитывать passive listeners).
  • mouse: mousedown/mousemove/mouseup (пока pointer не поддерживается).

Важно: используйте CSS touch-action (например, touch-action: pan-y или none) вместо event.preventDefault() в touchmove — это безопаснее и не конфликтует с пассивными слушателями. Подробнее — см. примеры выше.


Порог срабатывания и учёт скорости (velocity)

Порог — это как далеко пользователь перетащил слайд относительно его ширины. Простая формула:

  • thresholdDistance = slideWidth * 0.25; // 20–30% обычно удобно
  • velocity = deltaX / deltaTime; // px / ms

Решение переключиться:

  • если |deltaX| > thresholdDistance → переключаем;
  • иначе, если |velocity| > velocityThreshold → переключаем в сторону движения (пользователь «смахнул» быстро);
  • иначе → остаёмся на текущем слайде.

Рекомендации по значениям:

  • threshold: 0.2–0.3 ширины (т.е. 20–30%)
  • velocityThreshold: ~0.25–0.4 px/ms (250–400 px/s) — при тестировании выберите чувствительность под вашу верстку

Пример псевдокода:

js
const threshold = slideWidth * 0.25;
const velocity = deltaX / (now - startTime); // px/ms
if (Math.abs(deltaX) > threshold || Math.abs(velocity) > 0.3) {
 index = deltaX < 0 ? index + 1 : index - 1;
}

Учтите: для маленьких экранов velocity может срабатывать чаще — тестируйте.


Практические советы: отключение анимации, touch-action, производительность и доступность

  • Отключение анимации при drag: добавляйте класс .is-dragging и в нём transition: none для трека. Это убирает «плавное подтягивание» и делает отклик мгновенным.
  • touch-action: используйте touch-action: pan-y на контейнере, если хотите чтобы страница продолжала вертикально скроллиться, или touch-action: none, если хотите полностью перехватить жесты.
  • RequestAnimationFrame: обновляйте transform в RAF из pointermove, чтобы не перегружать основной поток.
  • will-change: will-change: transform на треке помогает оптимизации.
  • Отключите выделение текста и перетаскивание изображений: user-select: none; -webkit-user-drag: none;.
  • Обработка pointercancel — обязательно, чтобы корректно сбрасывать состояние.
  • Доступность: поддержите keyboard (стрелки влево/вправо), aria-roledescription="carousel", роли region и фокусируемые элементы внутри слайдов.
  • Тестируйте на реальных устройствах: чувствительность и скорость — вещь субъективная.

Полный пример кода (HTML / CSS / JS)

Ниже — минимальная, но полная реализация с pointer events, порогом и отключением transition во время перетаскивания.

/* HTML */

html
<div class="slider" id="slider">
 <div class="track">
 <div class="slide">1</div>
 <div class="slide">2</div>
 <div class="slide">3</div>
 </div>
</div>

/* CSS */

css
.slider {
 overflow: hidden;
 position: relative;
 touch-action: pan-y; /* разрешаем вертикальную прокрутку страницы */
 user-select: none;
}
.track {
 display: flex;
 transition: transform 300ms cubic-bezier(.22,.9,.37,1);
 will-change: transform;
}
.slider.is-dragging .track {
 transition: none; /* выключаем плавную анимацию во время drag */
}
.slide {
 flex: 0 0 100%;
 min-width: 100%;
 box-sizing: border-box;
 padding: 20px;
 text-align: center;
 font-size: 48px;
 background: #f4f4f4;
 border-right: 1px solid #ddd;
}

/* JS */

js
const slider = document.getElementById('slider');
const track = slider.querySelector('.track');
const slides = Array.from(track.children);

let currentIndex = 0;
let startX = 0;
let currentX = 0;
let deltaX = 0;
let startTime = 0;
let isDragging = false;
let slideWidth = slider.clientWidth;
let rafId = null;

function updateWidth() {
 slideWidth = slider.clientWidth;
 // выставляем корректный translate после ресайза
 track.style.transform = `translateX(${-currentIndex * slideWidth}px)`;
}
window.addEventListener('resize', updateWidth);
updateWidth();

function setTranslate(translate) {
 track.style.transform = `translateX(${translate}px)`;
}

slider.addEventListener('pointerdown', (e) => {
 if (e.pointerType === 'mouse' && e.button !== 0) return; // только левая кнопка
 isDragging = true;
 startX = e.clientX;
 currentX = startX;
 deltaX = 0;
 startTime = performance.now();
 slider.classList.add('is-dragging');
 slider.setPointerCapture(e.pointerId);
});

slider.addEventListener('pointermove', (e) => {
 if (!isDragging) return;
 currentX = e.clientX;
 deltaX = currentX - startX;
 if (!rafId) {
 rafId = requestAnimationFrame(() => {
 const translate = -currentIndex * slideWidth + deltaX;
 setTranslate(translate);
 rafId = null;
 });
 }
});

function endDrag(e) {
 if (!isDragging) return;
 isDragging = false;
 slider.classList.remove('is-dragging');
 const elapsed = Math.max(1, performance.now() - startTime); // ms
 const velocity = deltaX / elapsed; // px / ms
 const threshold = slideWidth * 0.25; // 25%

 let newIndex = currentIndex;
 if (Math.abs(deltaX) > threshold || Math.abs(velocity) > 0.3) {
 newIndex = deltaX < 0 ? currentIndex + 1 : currentIndex - 1;
 }
 newIndex = Math.max(0, Math.min(slides.length - 1, newIndex));
 currentIndex = newIndex;

 // включаем transition (класс убрал transition при drag)
 setTranslate(-currentIndex * slideWidth);

 // релизим pointer capture
 try { slider.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ }
}

slider.addEventListener('pointerup', endDrag);
slider.addEventListener('pointercancel', endDrag);

// предотвращаем перетаскивание изображения/текста браузером
slider.addEventListener('dragstart', (e) => e.preventDefault());

Этот пример — основа. Для продакшена добавьте обработку клавиш, индикаторы, бесшовную цикличность (loop) и ограничения на множественные срабатывания.


Источники


Заключение

Резкое (snap) переключение в слайдере достигается двумя путями: нативным CSS scroll-snap для простых случаев и ручной реализацией на JavaScript для тонкой настройки порога, учёта скорости и UX. Главные приёмы в JS: использовать pointerdown/pointermove/pointerup, временно выключать transition при перетаскивании, вычислять порог как долю ширины слайда и учитывать скорость при отпускании — затем включать transition и устанавливать итоговый translateX для snap. Тестируйте чувствительность (threshold/velocity) на реальных устройствах и не забывайте про touch-action и доступность.

Авторы
Проверено модерацией
Модерация