SSS - Единый стандарт для слайдеров

SSS - Единый стандарт для слайдеров
Стандарты

35

9 мин.

Определения

Стандарт SSS определяет требования к семантике, доступности и логике работы слайдеров.

Требования применяются с учётом поведения конкретного компонента: конечной или циклической навигации, ручного или автоматического переключения, а также числа одновременно видимых слайдов.

В примерах для реализации используется Embla Carousel с React-адаптером. Библиотека служит примером, а не обязательной зависимостью стандарта; API в примерах соответствует Embla v8.

Структура и Семантика

Навигация и границы слайдов

  • Для перехода между слайдами используются нативные <button>. Ссылки применяются только для перехода на другой адрес, а не для управления слайдером.
  • В слайдере с конечной последовательностью кнопка «назад» отключается на первом слайде, а кнопка «вперёд» — на последнем. Для циклической навигации эти кнопки остаются доступными, если переход через границу предусмотрен логикой компонента.
  • Неактивное состояние кнопки задаётся нативным атрибутом disabled. Внешний вид должен отличаться от активного состояния; допустимы, например, изменение opacity и курсор not-allowed. Одного визуального оформления недостаточно для передачи состояния вспомогательным технологиям.
  • Стрелки и переключатели конкретного слайда скрываются, если все элементы одновременно помещаются в область просмотра и переход между позициями не требуется. Решение принимается по фактическому переполнению, а не только по общему числу слайдов.
  • Наличие переполнения проверяется при инициализации и при изменении размеров области просмотра или содержимого. Для этого подходят измерения контейнера и ленты слайдов совместно с ResizeObserver; одной подписки на window.resize недостаточно, поскольку размеры могут измениться без изменения окна.
  • После обновления данных, загрузки шрифтов и пересчёта адаптивной раскладки проверка переполнения выполняется повторно, если эти изменения влияют на размеры слайдов.
  • В Embla состояние кнопок «назад» и «вперёд» синхронизируется с canScrollPrev() и canScrollNext() при инициализации, а также при событиях select и reInit. Кнопки размещаются вне узла viewport, который обрабатывает перетаскивание.

Адаптивность

  • Количество видимых слайдов и расстояние gap задаются с учётом доступной ширины компонента. Для разных макетов подходят медиазапросы или container queries — выбор зависит от того, определяется ли раскладка шириной экрана или родительского контейнера.
  • На узком экране частичный показ соседнего слайда, например на 10–20 px, можно использовать как визуальный признак продолжения содержимого. Это рекомендация по интерфейсу, а не обязательная величина: размер видимой части подбирается с учётом ширины контейнера, отступов и направления письма.
  • Частично видимый слайд не должен перекрывать элементы управления, обрезать важный текст или создавать горизонтальную прокрутку всей страницы.
  • Направление перемещения, порядок элементов и подписи навигации должны соответствовать направлению чтения интерфейса. Для RTL-интерфейса проверяются логика «назад»/«вперёд» и ориентация визуальных стрелок.

Доступность и Интерактивность

Семантика и доступные имена

  • Контейнер слайдера получает роль region, если это значимая область страницы, или group, если отдельная landmark-область не нужна. Тип компонента задаётся через aria-roledescription со значением «карусель».
  • Контейнеру задаётся доступное имя через видимый заголовок и aria-labelledby либо, если видимого заголовка нет, через aria-label. При использовании aria-roledescription слово «карусель» не требуется повторять в доступном имени.
  • Каждый слайд размечается как role="group" с aria-roledescription со значением «слайд» и получает понятное имя: по заголовку слайда либо, если отдельного заголовка нет, по позиции, например «2 из 5». При заданном aria-roledescription слово «слайд» в имени не дублируется.
  • Кнопкам навигации задаются доступные имена, например aria-label="Предыдущий слайд" и aria-label="Следующий слайд". Видимая подпись может служить доступным именем без aria-label.
  • Кнопки выбора слайда или группы слайдов группируются и получают доступное имя для всей группы. Для активного переключателя используется семантика, соответствующая выбранному паттерну: aria-disabled="true" для группы кнопок-переключателей по APG или aria-selected="true" для реализации на основе вкладок. aria-current="true" не задаётся самому контейнеру слайда автоматически: состояние текущей позиции обозначается на элементе управления способом, соответствующим выбранному паттерну.
  • Слайды, визуально скрытые за пределами области просмотра, не должны оставлять фокусируемые элементы в недоступной визуально позиции. aria-hidden="true" применяется только при согласованном исключении содержимого из дерева доступности и управления фокусом. Частично видимый слайд не считается скрытым.

Буллеты (пагинация)

  • Буллеты являются дополнительной навигацией, а не обязательным элементом каждого слайдера. Они подходят, когда переход к конкретной позиции полезен и число позиций не создаёт чрезмерное количество элементов в Tab-порядке.
  • Один буллет соответствует одной позиции прокрутки (snap point), а не обязательно одному слайду. Если за один шаг перемещается группа слайдов, подпись буллета должна описывать соответствующую группу. В Embla число позиций берётся из scrollSnapList(), а переход выполняется через scrollTo(index).
  • Активный буллет визуально отличается от остальных и программно обозначается согласно выбранному паттерну. Для группы обычных кнопок-переключателей используется aria-disabled="true" на активной кнопке по паттерну APG; для варианта на вкладках требуется полный паттерн tabs, включая aria-selected и клавиатурное управление вкладками. Одного изменения цвета или прозрачности недостаточно.
  • У каждой кнопки-буллета есть доступное имя, описывающее назначение и позицию, например aria-label="Перейти к позиции 2 из 5". Для сгруппированных слайдов подпись уточняет диапазон или содержание группы.
  • Буллеты реализуются нативными <button> с заметным :focus-visible. Область нажатия должна соответствовать WCAG 2.2 SC 2.5.8: не менее 24 × 24 CSS px либо применимому исключению по интервалам, эквивалентному элементу управления или другому условию критерия.
  • В Embla контейнер буллетов размещается вне viewport, переданного в useEmblaCarousel: этот узел обрабатывает указатель и перетаскивание, что может мешать нажатию на расположенные внутри него кнопки.
  • Состояние буллетов синхронизируется с Embla при инициализации, событии select и повторной инициализации reInit. После изменения ширины контейнера или состава слайдов число snap points и набор буллетов могут измениться.

Клавиатура, перетаскивание и автоматическое переключение

  • Все действия, доступные указателем или касанием, также выполняются с клавиатуры. Для навигации используются кнопки и стандартный порядок Tab; активация стрелок или переключателя не должна неожиданно переносить фокус.
  • При поддержке перетаскивания указателем курсор на области перетаскивания может быть grab, а во время активного перетаскивания — grabbing. Этот визуальный сигнал не заменяет кнопки управления, клавиатурную навигацию или касание.
  • Жест горизонтального перелистывания не должен мешать вертикальной прокрутке страницы, выделению текста и работе вложенных интерактивных элементов.
  • Для ручного слайдера слайдовая область может иметь aria-live="polite", чтобы изменения объявлялись вспомогательными технологиями без прерывания текущей речи. Для автоматически вращающегося слайдера используется aria-live="off", чтобы обновления не объявлялись непрерывно.
  • Если слайды переключаются автоматически, предусматривается доступная кнопка остановки и возобновления. Автоматическое переключение останавливается при получении фокуса любым элементом слайдера и при наведении указателя; возобновление не происходит без явного действия пользователя.
  • Анимация учитывает системную настройку prefers-reduced-motion: при запросе уменьшения движения переходы сокращаются или отключаются, если они не нужны для понимания состояния.

Высота и Производительность

Высота и устойчивость макета

  • height: auto не запрещается как общее правило. Само по себе автоматическое определение высоты не вызывает скачок; смещение возникает, если высота области меняется при переключении содержимого без предусмотренного поведения.
  • Если стабильная высота важна для макета, пространство резервируется через соотношение сторон, min-height или согласованную высоту карточек. Фиксированная высота не должна обрезать содержимое при увеличении текста, смене языка или увеличении масштаба.
  • Для слайдов переменной высоты допустимо подстраивать область под активный слайд, если такое изменение предусмотрено дизайном и не создаёт неожиданного смещения окружающего содержимого. Высоту проверяют на реальном контенте, включая длинные заголовки и локализованные версии.

Анимация и прокрутка

  • Для анимации перемещения предпочтительны transform и, где уместно, opacity: изменение left или margin может вызывать перерасчёт макета. Выбор проверяется профилированием — ни одно CSS-свойство само по себе не гарантирует плавность.
  • will-change: transform не добавляется ко всем слайдам по умолчанию. Свойство применяется только при подтверждённой проблеме производительности, поскольку постоянное резервирование ресурсов для множества слоёв может ухудшить производительность.
  • Для слайдера, построенного на нативной горизонтальной прокрутке, можно использовать CSS Scroll Snap. В таком варианте сохраняются ожидаемая прокрутка браузера и возможность взаимодействия с сенсорными и вспомогательными технологиями; поведение проверяется на целевых браузерах.

Загрузка изображений

  • Для изображений, видимых при первоначальной загрузке, включая критичный первый слайд, не задаётся loading="lazy". Ленивую загрузку можно использовать для изображений, которые изначально находятся за пределами области просмотра.
  • loading="lazy" не задаёт точное расстояние в один или два слайда: момент начала загрузки выбирает браузер на основе близости к области просмотра и других условий. Для ранней загрузки следующего слайда требуется отдельное решение, обоснованное измерениями и бюджетом сетевых ресурсов.
  • Для изображений задаются width и height либо заранее известное соотношение сторон. Это резервирует место до загрузки и снижает риск смещения макета.
  • При использовании srcset и sizes значения должны соответствовать адаптивной ширине изображения внутри слайда. Не следует загружать полноразмерные изображения для миниатюр без необходимости.

Проверка

  • Кнопки «назад» и «вперёд» имеют корректные состояния для конечного и циклического режимов; disabled доступен вспомогательным технологиям и заметен визуально.
  • Стрелки и переключатели скрыты только при отсутствии необходимости навигации; переполнение проверяется после инициализации и изменений размера контейнера и содержимого.
  • Слайдер работает с клавиатуры, указателя и сенсорного ввода; фокус не теряется и не перемещается неожиданно.
  • Доступные имена контейнера, слайдов, стрелок и буллетов понятны; текущая позиция передаётся выбранной семантикой, а автоматическое вращение можно остановить.
  • Число буллетов совпадает с числом позиций прокрутки, их состояние синхронизируется после перетаскивания, выбора позиции и ресайза; размер или интервалы целей проверены по WCAG 2.2 SC 2.5.8.
  • Проверены aria-live для ручного и автоматического режимов, системная настройка уменьшения движения и поведение слайдов за пределами видимой области.
  • На мобильных размерах проверены частичный показ соседнего слайда, вертикальная прокрутка страницы и отсутствие горизонтального переполнения документа.
  • Изображения резервируют место до загрузки; начальный критичный слайд не откладывается ленивой загрузкой.
  • Плавность анимации и смещение макета проверены на целевых устройствах и браузерах, а не только по наличию transform или will-change.

Итоговые примеры

Ручной слайдер Embla с навигацией и буллетами:

'use client'

import { useCallback, useEffect, useState } from 'react'
import useEmblaCarousel from 'embla-carousel-react'
import type { EmblaCarouselType } from 'embla-carousel'

const slides = [
  { id: 'article-1', title: 'Первая статья', image: '/images/article-1.webp' },
  { id: 'article-2', title: 'Вторая статья', image: '/images/article-2.webp' },
  { id: 'article-3', title: 'Третья статья', image: '/images/article-3.webp' },
]

export const ArticleCarousel = () => {
  const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false, slidesToScroll: 1 })
  const [scrollSnaps, setScrollSnaps] = useState<number[]>([])
  const [selectedSnap, setSelectedSnap] = useState(0)

  const onSelect = useCallback((api: EmblaCarouselType) => {
    setSelectedSnap(api.selectedScrollSnap())
  }, [])

  const onReInit = useCallback((api: EmblaCarouselType) => {
    setScrollSnaps(api.scrollSnapList())
    setSelectedSnap(api.selectedScrollSnap())
  }, [])

  useEffect(() => {
    if (!emblaApi) return

    onReInit(emblaApi)
    emblaApi.on('select', onSelect)
    emblaApi.on('reInit', onReInit)

    return () => {
      emblaApi.off('select', onSelect)
      emblaApi.off('reInit', onReInit)
    }
  }, [emblaApi, onReInit, onSelect])

  return (
    <section
      className='embla'
      role='region'
      aria-roledescription='карусель'
      aria-labelledby='featured-title'
    >
      <h2 id='featured-title'>Рекомендуемые статьи</h2>

      <div
        className='embla__viewport'
        ref={emblaRef}
        aria-live='polite'
        aria-atomic='false'
      >
        <div className='embla__container'>
          {slides.map((slide) => (
            <article
              className='embla__slide'
              key={slide.id}
              role='group'
              aria-roledescription='слайд'
              aria-labelledby={`slide-title-${slide.id}`}
            >
              <img
                src={slide.image}
                width='960'
                height='640'
                alt={`Обложка: ${slide.title}`}
              />
              <h3 id={`slide-title-${slide.id}`}>{slide.title}</h3>
            </article>
          ))}
        </div>
      </div>

      {scrollSnaps.length > 1 && (
        <div className='embla__controls'>
          <button
            type='button'
            aria-label='Предыдущий слайд'
            disabled={!emblaApi?.canScrollPrev()}
            onClick={() => emblaApi?.scrollPrev()}
          >
            <span aria-hidden='true'>←</span>
          </button>
          <button
            type='button'
            aria-label='Следующий слайд'
            disabled={!emblaApi?.canScrollNext()}
            onClick={() => emblaApi?.scrollNext()}
          >
            <span aria-hidden='true'>→</span>
          </button>

          <div className='embla__dots' role='group' aria-label='Выбор позиции слайдера'>
            {scrollSnaps.map((_, index) => {
              const isSelected = index === selectedSnap

              return (
                <button
                  className='embla__dot'
                  type='button'
                  key={index}
                  aria-label={`Перейти к позиции ${index + 1} из ${scrollSnaps.length}`}
                  aria-disabled={isSelected}
                  onClick={() => {
                    if (!isSelected) emblaApi?.scrollTo(index)
                  }}
                >
                  <span aria-hidden='true' />
                </button>
              )
            })}
          </div>
        </div>
      )}
    </section>
  )
};

В примере навигация ручная, поэтому для области слайдов задано aria-live="polite". Контейнер кнопок расположен вне .embla__viewport, чтобы перетаскивание не мешало нажатию на кнопки. scrollSnapList() формирует буллеты по позициям прокрутки, а состояние обновляется при select и reInit. Атрибут loading не указан, поскольку число изображений в начальной области просмотра зависит от адаптивной раскладки; в рабочем компоненте видимые при загрузке изображения не откладываются, а изображения вне начальной области могут загружаться лениво. Если используется pnpm и импортируется EmblaCarouselType, пакет embla-carousel также добавляется как прямая dev-зависимость.

Адаптивная раскладка Embla с частичным показом следующей карточки и доступной областью буллета:

.embla__viewport {
  overflow: hidden;
  cursor: grab;
  touch-action: pan-y pinch-zoom;
}

.embla__viewport:active {
  cursor: grabbing;
}

.embla__container {
  display: flex;
  gap: 12px;
}

.embla__slide {
  flex: 0 0 calc(100% - 28px);
  min-width: 0;
}

.embla__dot {
  display: inline-grid;
  min-width: 24px;
  min-height: 24px;
  place-items: center;
  border: 0;
  background: transparent;
  cursor: pointer;
}

.embla__dot span {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: currentColor;
}

.embla__dot[aria-disabled='true'] span {
  width: 12px;
  height: 12px;
}

.embla__dot:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

@media (min-width: 768px) {
  .embla__slide {
    flex-basis: calc((100% - 24px) / 3);
  }
}

При ширине первой карточки calc(100% - 28px) и gap: 12px в viewport остаётся около 16 px следующей карточки. Это иллюстрация одного макета: количество видимых карточек, gap, видимая часть соседнего слайда и hit area буллетов проверяются на фактической ширине контейнера. При использовании Embla v8 API-примеры сверяются с документацией этой major-версии.

Похожие статьи

  • Анатомия React компонента

    Моё видение того, как должен выглядеть React компонент

    421

    7 мин.

  • SST-A - Единый стандарт для табов

    Стандарт SST-A определяет требования к семантике, доступности и логике работы табов

    62

    6 мин.

  • SSF-U - Единый стандарт для фуллскрина

    Стандарт SSF-U определяет требования к семантике, доступности и логике работы фуллскрина

    248

    2 мин.

  • SSA - Единый стандарт для аккордеона

    Стандарт SSA определяет требования к семантике, доступности и логике работы аккордеона

    258

    2 мин.

  • Bad Practices для сайтов

    Разбор критических ошибок веб-дизайна. Почему слайдеры, автоплей и тяжёлые страницы снижают конверсию и позиции в Google и Yandex

    232

    1 мин.

  • SSP - Единый стандарт для пагинации

    Стандарт SSP определяет требования к семантике, доступности и логике работы пагинации

    240

    1 мин.

  • Все статьи

Свяжитесь со мной