Слайдер (Карусель)

Зачем нужно

Слайдер (carousel) -- компонент для последовательного показа контента: изображений, карточек, отзывов. Два основных подхода: CSS scroll-snap (нативный скролл) и JS-карусель с программным управлением. Оба требуют внимания к доступности, touch-управлению и производительности.

Где используется

  • Галерея изображений продукта
  • Слайдер с отзывами клиентов
  • Карусель карточек товаров
  • Hero-баннер на главной странице
  • Онбординг (пошаговый тур)

Подход 1: CSS Scroll Snap (нативный)

Самый производительный вариант -- используем нативный скролл браузера с "прилипанием" к слайдам.

HTML

<div class="snap-carousel" role="group" aria-label="Галерея изображений"
     aria-roledescription="carousel">
  <div class="snap-carousel__track">
    <div class="snap-carousel__slide" role="group" aria-roledescription="slide"
         aria-label="Слайд 1 из 4">
      <img src="img/slide-1.jpg" alt="Описание изображения 1" />
    </div>
    <div class="snap-carousel__slide" role="group" aria-roledescription="slide"
         aria-label="Слайд 2 из 4">
      <img src="img/slide-2.jpg" alt="Описание изображения 2" />
    </div>
    <div class="snap-carousel__slide" role="group" aria-roledescription="slide"
         aria-label="Слайд 3 из 4">
      <img src="img/slide-3.jpg" alt="Описание изображения 3" />
    </div>
    <div class="snap-carousel__slide" role="group" aria-roledescription="slide"
         aria-label="Слайд 4 из 4">
      <img src="img/slide-4.jpg" alt="Описание изображения 4" />
    </div>
  </div>
</div>

CSS

.snap-carousel__track {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  gap: 16px;

  /* Скрыть скроллбар, но сохранить скролл */
  scrollbar-width: none;
}

.snap-carousel__track::-webkit-scrollbar {
  display: none;
}

.snap-carousel__slide {
  flex: 0 0 100%;
  scroll-snap-align: start;
}

.snap-carousel__slide img {
  width: 100%;
  height: 400px;
  object-fit: cover;
  border-radius: 12px;
}

/* Для нескольких карточек на экране */
@media (min-width: 768px) {
  .snap-carousel__slide {
    flex: 0 0 calc(33.333% - 11px); /* 3 карточки с gap */
  }
}

Подход 2: JS-карусель с кнопками и dots

Полноценная карусель с кнопками "вперёд/назад", точками пагинации и touch-поддержкой.

HTML

<div class="carousel" role="group" aria-label="Карусель отзывов"
     aria-roledescription="carousel">
  <div class="carousel__viewport">
    <div class="carousel__track" aria-live="polite">
      <div class="carousel__slide active">
        <blockquote>
          <p>Отличный сервис, рекомендую!</p>
          <cite>-- Алексей</cite>
        </blockquote>
      </div>
      <div class="carousel__slide">
        <blockquote>
          <p>Быстрая доставка, качественный товар.</p>
          <cite>-- Мария</cite>
        </blockquote>
      </div>
      <div class="carousel__slide">
        <blockquote>
          <p>Лучший магазин в категории.</p>
          <cite>-- Дмитрий</cite>
        </blockquote>
      </div>
    </div>
  </div>

  <!-- Кнопки навигации -->
  <button class="carousel__btn carousel__btn--prev" aria-label="Предыдущий слайд">
    &#8592;
  </button>
  <button class="carousel__btn carousel__btn--next" aria-label="Следующий слайд">
    &#8594;
  </button>

  <!-- Точки пагинации -->
  <div class="carousel__dots" role="tablist" aria-label="Навигация по слайдам">
    <button class="carousel__dot active" role="tab" aria-selected="true"
            aria-label="Слайд 1"></button>
    <button class="carousel__dot" role="tab" aria-selected="false"
            aria-label="Слайд 2"></button>
    <button class="carousel__dot" role="tab" aria-selected="false"
            aria-label="Слайд 3"></button>
  </div>
</div>

CSS

.carousel {
  position: relative;
  max-width: 800px;
  margin: 0 auto;
}

.carousel__viewport {
  overflow: hidden;
  border-radius: 12px;
}

.carousel__track {
  display: flex;
  transition: transform 0.4s ease;
}

.carousel__slide {
  flex: 0 0 100%;
  padding: 48px 32px;
  box-sizing: border-box;
}

/* Кнопки Prev / Next */
.carousel__btn {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 44px;
  height: 44px;
  border-radius: 50%;
  border: none;
  background: rgba(255, 255, 255, 0.9);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  font-size: 18px;
  cursor: pointer;
  transition: background 0.2s;
  z-index: 2;
}

.carousel__btn:hover {
  background: #fff;
}

.carousel__btn--prev { left: 12px; }
.carousel__btn--next { right: 12px; }

/* Dots */
.carousel__dots {
  display: flex;
  justify-content: center;
  gap: 8px;
  margin-top: 16px;
}

.carousel__dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  border: none;
  background: #ccc;
  cursor: pointer;
  padding: 0;
  transition: background 0.2s, transform 0.2s;
}

.carousel__dot.active {
  background: #333;
  transform: scale(1.3);
}

JavaScript

class Carousel {
  constructor(element) {
    this.carousel = element;
    this.track = element.querySelector('.carousel__track');
    this.slides = [...element.querySelectorAll('.carousel__slide')];
    this.dots = [...element.querySelectorAll('.carousel__dot')];
    this.prevBtn = element.querySelector('.carousel__btn--prev');
    this.nextBtn = element.querySelector('.carousel__btn--next');

    this.currentIndex = 0;
    this.autoplayTimer = null;
    this.touchStartX = 0;
    this.touchEndX = 0;

    this.init;
  }

  init {
    this.prevBtn.addEventListener('click', () => this.prev);
    this.nextBtn.addEventListener('click', () => this.next());

    this.dots.forEach((dot, i) => {
      dot.addEventListener('click', () => this.goTo(i));
    });

    // Touch/Swipe
    this.track.addEventListener('touchstart', (e) => {
      this.touchStartX = e.changedTouches[0].screenX;
      this.stopAutoplay;
    }, { passive: true });

    this.track.addEventListener('touchend', (e) => {
      this.touchEndX = e.changedTouches[0].screenX;
      this.handleSwipe;
    });

    // Пауза autoplay при hover
    this.carousel.addEventListener('mouseenter', () => this.stopAutoplay);
    this.carousel.addEventListener('mouseleave', () => this.startAutoplay);
  }

  goTo(index) {
    this.currentIndex = (index + this.slides.length) % this.slides.length;
    this.track.style.transform = `translateX(-${this.currentIndex * 100}%)`;
    this.updateDots;
    this.updateSlides;
  }

  prev { this.goTo(this.currentIndex - 1); }
  next { this.goTo(this.currentIndex + 1); }

  updateDots {
    this.dots.forEach((dot, i) => {
      dot.classList.toggle('active', i === this.currentIndex);
      dot.setAttribute('aria-selected', i === this.currentIndex);
    });
  }

  updateSlides {
    this.slides.forEach((slide, i) => {
      slide.classList.toggle('active', i === this.currentIndex);
    });
  }

  handleSwipe {
    const diff = this.touchStartX - this.touchEndX;
    const threshold = 50; // Минимальное расстояние свайпа

    if (Math.abs(diff) > threshold) {
      diff > 0 ? this.next() : this.prev;
    }
    this.startAutoplay;
  }

  startAutoplay(interval = 5000) {
    this.stopAutoplay;
    this.autoplayTimer = setInterval( => this.next(), interval);
  }

  stopAutoplay {
    if (this.autoplayTimer) {
      clearInterval(this.autoplayTimer);
      this.autoplayTimer = null;
    }
  }
}

// Инициализация
const carousel = new Carousel(document.querySelector('.carousel'));
carousel.startAutoplay;

Autoplay с паузой

Autoplay удобен, но должен останавливаться при взаимодействии пользователя.

// Пауза при focus внутри карусели (доступность)
carousel.addEventListener('focusin', () => this.stopAutoplay);
carousel.addEventListener('focusout', () => this.startAutoplay);

// Пауза при prefers-reduced-motion
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
if (prefersReducedMotion.matches) {
  carousel.stopAutoplay;
}

Accessibility-чеклист

Атрибут Назначение
aria-roledescription="carousel" Описание компонента
aria-roledescription="slide" Описание слайда
aria-label="Слайд N из M" Текущая позиция
aria-live="polite" Анонсирование смены слайда
role="tablist" + role="tab" Для dots-навигации
Кнопки с aria-label Описание действия
Пауза autoplay при focus Скринридер не теряет позицию

Частые ошибки

Ошибка Проблема Решение
Нет touch/swipe Не работает на мобильных Добавь touchstart/touchend
Autoplay без паузы Невозможно прочитать контент Пауза при hover/focus
Нет dots-навигации Нет индикации позиции Добавь пагинацию
aria-live отсутствует Скринридер не анонсирует смену Добавь aria-live="polite"
Слайдер дёргается Нет will-change: transform Оптимизируй анимацию

Связанные темы

Ресурсы