
Задача
Типовая задача — сайт-визитка. Вся нестандартность собралась в карусели меню: не слайдер из коробки, а самописная штука, которая должна работать при трёх условиях одновременно — переменное число видимых карточек, фильтрация по категориям с пересчётом и прокрутка, управляемая снаружи.
Как делал
Внутри контейнера display: flex с overflow-x: auto, прокрутка нативная через scrollTo({ behavior: 'smooth' }). Никакого transform: translateX() с пересчётом индекса — браузер сам рисует плавный скролл, а JS только выставляет целевую позицию в пикселях.
Главная возня — синхронизация состояния. Листать можно стрелками, свайпом или просто пальцем по overflow-x, и всё это должно вести один и тот же currentIndex. Отсюда флаг isAnimating: пока идёт программный скролл, обработчик нативного scroll игнорируется, иначе он ловит промежуточные значения и перезаписывает индекс в середине анимации.
menuItemsContainer.addEventListener('scroll', () => {
// Полностью игнорируем скролл во время программной анимации
if (isAnimating) return;
const currentScrollLeft = menuItemsContainer.scrollLeft;
scrollDirection = currentScrollLeft > lastScrollLeft ? 1 : -1;
lastScrollLeft = currentScrollLeft;
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
// Дополнительная проверка после дебаунса
if (isAnimating) return;
const items = getVisibleItems();
if (items.length === 0) return;
const itemWidth = getItemWidth();
const scrollLeft = menuItemsContainer.scrollLeft;
const newIndex = Math.round(scrollLeft / (itemWidth + GAP));
const maxIndex = getMaxIndex();
if (newIndex >= 0 && newIndex <= maxIndex && newIndex !== currentIndex) {
currentIndex = newIndex;
updateDots();
updateButtons();
}
}, 150);
});
function setupAnimationCompleteHandler() {
const itemWidth = getItemWidth();
const targetScrollPosition = currentIndex * (itemWidth + GAP);
const completeAnimation = () => {
if (!isAnimating) return;
cleanupAnimation();
const currentPos = menuItemsContainer.scrollLeft;
if (Math.abs(currentPos - targetScrollPosition) > 5) {
menuItemsContainer.scrollTo({
left: targetScrollPosition,
behavior: 'instant'
});
}
};
if ('onscrollend' in window) {
scrollEndHandler = () => {
requestAnimationFrame(() => {
requestAnimationFrame(completeAnimation);
});
};
menuItemsContainer.addEventListener('scrollend', scrollEndHandler, { once: true });
}
animationTimeout = setTimeout(completeAnimation, 600);
}
Фильтрация — это display: none для ненужных карточек и повторный initCarousel() через 50 мс: после смены display браузеру нужно пересчитать layout, иначе clientWidth вернёт старые значения. Точки и максимум индекса считаются от видимых элементов, поэтому один и тот же код обслуживает и «Все», и любую отдельную категорию.
const dateInput = document.getElementById('date');
if (dateInput) {
dateInput.setAttribute('min', new Date().toISOString().split('T')[0]);
}
const phoneInput = document.getElementById('phone');
if (phoneInput) {
phoneInput.addEventListener('input', (e) => {
let value = e.target.value.replace(/\D/g, '');
if (value.length > 0) {
if (value[0] === '7' || value[0] === '8') value = value.substring(1);
let formatted = '+7 (';
if (value.length > 0) formatted += value.substring(0, 3);
if (value.length >= 3) formatted += ') ' + value.substring(3, 6);
if (value.length >= 6) formatted += '-' + value.substring(6, 8);
if (value.length >= 8) formatted += '-' + value.substring(8, 10);
e.target.value = formatted;
}
});
}
Итог
Карусель не ломается при смене фильтра, ресайзе и свайпах: стрелки дизейблятся на краях, точки синхронизированы с индексом. Вся её логика — около двухсот строк ванильного JS без зависимостей.
