Guides
Accessibility
Roles, labels, keyboard support, a live region and reduced motion — all of it plain markup you own.
All of it is your markup
The hook generates no attributes and no strings. Roles, labels, the tab index, the arrow keys and the live region are ordinary markup here: reword them, translate them, drop what you do not need. The Accessible tab below is the whole carousel with all of it in place; Minimal is the same carousel with none of it.
import { useSwipiCarousel } from '@midstem/swipi-react'
export const Carousel = ({ items }) => {
const [carouselRef, carousel] = useSwipiCarousel({ respectReducedMotion: true })
return (
<>
<div className="overflow-hidden touch-pan-y" ref={carouselRef}>
<div className="flex cursor-grab select-none active:cursor-grabbing">
{items.map((item) => (
<div className="min-w-0 shrink-0 grow-0 basis-full" key={item.id}>
{item.title}
</div>
))}
</div>
</div>
<button
type="button"
className="z-10 bg-transparent border-none cursor-pointer disabled:opacity-[0.35] disabled:cursor-default"
onClick={carousel.scrollPrev}
disabled={!carousel.canScrollPrev}
>
‹
</button>
<button
type="button"
className="z-10 bg-transparent border-none cursor-pointer disabled:opacity-[0.35] disabled:cursor-default"
onClick={carousel.scrollNext}
disabled={!carousel.canScrollNext}
>
›
</button>
<nav className="relative flex items-center gap-2.5">
{Array.from({ length: carousel.snapCount }, (_, index) => (
<button
type="button"
className="h-3 w-3 p-0 bg-[#d2d2d2] border-none rounded-full cursor-pointer data-[active=true]:bg-[#b70808]"
key={index}
data-active={index === carousel.selectedIndex}
onClick={() => carousel.scrollTo(index)}
/>
))}
</nav>
</>
)
}What each part does
- The viewport is a focusable
role="group"witharia-roledescription="carousel"and a label you choose. - Every slide is a labelled group —
"2 of 5"— so a screen reader announces where it is in the set. - ← and → move between snaps while the viewport has focus. The listener is your own; the hook never replaces it.
- A polite live region announces the slide that was selected, whether the move came from a key, a dot or a drag.
- Arrows and dots are real
<button>s with labels,aria-currentand adisabledstate that followscanScrollPrevandcanScrollNext.
The live region has to be in the DOM to be announced, so hide it visually rather than with display: none — the sr-only class in Tailwind, or the rule below.
.carousel__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}Reduced motion
The carousel animates the same for everyone until you pass respectReducedMotion. With it the carousel watches prefers-reduced-motion and jumps straight to the target while the system asks for less motion — the position still changes, the travel does not.
const [carouselRef, carousel] = useSwipiCarousel({
respectReducedMotion: true
})Translation
Because it is your code, translating it is editing it — no options to look up, no strings the library decides for you.
<span className="carousel__status" aria-live="polite" aria-atomic="true">
Слайд {carousel.selectedIndex + 1} з {carousel.snapCount}
</span>