Анатомия React компонента
Моё видение того, как должен выглядеть React компонент
421
7 мин.
Стандарт SSS определяет требования к семантике, доступности и логике работы слайдеров.
Требования применяются с учётом поведения конкретного компонента: конечной или циклической навигации, ручного или автоматического переключения, а также числа одновременно видимых слайдов.
В примерах для реализации используется Embla Carousel с React-адаптером. Библиотека служит примером, а не обязательной зависимостью стандарта; API в примерах соответствует Embla v8.
<button>. Ссылки применяются только для перехода на другой адрес, а не для управления слайдером.disabled. Внешний вид должен отличаться от активного состояния; допустимы, например, изменение opacity и курсор not-allowed. Одного визуального оформления недостаточно для передачи состояния вспомогательным технологиям.ResizeObserver; одной подписки на window.resize недостаточно, поскольку размеры могут измениться без изменения окна.canScrollPrev() и canScrollNext() при инициализации, а также при событиях select и reInit. Кнопки размещаются вне узла viewport, который обрабатывает перетаскивание.gap задаются с учётом доступной ширины компонента. Для разных макетов подходят медиазапросы или container queries — выбор зависит от того, определяется ли раскладка шириной экрана или родительского контейнера.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" применяется только при согласованном исключении содержимого из дерева доступности и управления фокусом. Частично видимый слайд не считается скрытым.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 либо применимому исключению по интервалам, эквивалентному элементу управления или другому условию критерия.useEmblaCarousel: этот узел обрабатывает указатель и перетаскивание, что может мешать нажатию на расположенные внутри него кнопки.select и повторной инициализации reInit. После изменения ширины контейнера или состава слайдов число snap points и набор буллетов могут измениться.grab, а во время активного перетаскивания — grabbing. Этот визуальный сигнал не заменяет кнопки управления, клавиатурную навигацию или касание.aria-live="polite", чтобы изменения объявлялись вспомогательными технологиями без прерывания текущей речи. Для автоматически вращающегося слайдера используется aria-live="off", чтобы обновления не объявлялись непрерывно.prefers-reduced-motion: при запросе уменьшения движения переходы сокращаются или отключаются, если они не нужны для понимания состояния.height: auto не запрещается как общее правило. Само по себе автоматическое определение высоты не вызывает скачок; смещение возникает, если высота области меняется при переключении содержимого без предусмотренного поведения.min-height или согласованную высоту карточек. Фиксированная высота не должна обрезать содержимое при увеличении текста, смене языка или увеличении масштаба.transform и, где уместно, opacity: изменение left или margin может вызывать перерасчёт макета. Выбор проверяется профилированием — ни одно CSS-свойство само по себе не гарантирует плавность.will-change: transform не добавляется ко всем слайдам по умолчанию. Свойство применяется только при подтверждённой проблеме производительности, поскольку постоянное резервирование ресурсов для множества слоёв может ухудшить производительность.loading="lazy". Ленивую загрузку можно использовать для изображений, которые изначально находятся за пределами области просмотра.loading="lazy" не задаёт точное расстояние в один или два слайда: момент начала загрузки выбирает браузер на основе близости к области просмотра и других условий. Для ранней загрузки следующего слайда требуется отдельное решение, обоснованное измерениями и бюджетом сетевых ресурсов.width и height либо заранее известное соотношение сторон. Это резервирует место до загрузки и снижает риск смещения макета.srcset и sizes значения должны соответствовать адаптивной ширине изображения внутри слайда. Не следует загружать полноразмерные изображения для миниатюр без необходимости.disabled доступен вспомогательным технологиям и заметен визуально.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 компонент
421
7 мин.
Стандарт SST-A определяет требования к семантике, доступности и логике работы табов
62
6 мин.
Стандарт SSF-U определяет требования к семантике, доступности и логике работы фуллскрина
248
2 мин.
Стандарт SSA определяет требования к семантике, доступности и логике работы аккордеона
258
2 мин.
Разбор критических ошибок веб-дизайна. Почему слайдеры, автоплей и тяжёлые страницы снижают конверсию и позиции в Google и Yandex
232
1 мин.
Стандарт SSP определяет требования к семантике, доступности и логике работы пагинации
240
1 мин.