SwipiDocumentation

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.

Markup
Styles
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" with aria-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-current and a disabled state that follows canScrollPrev and canScrollNext.

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.

Visually hidden
.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.

React
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.

Ukrainian
<span className="carousel__status" aria-live="polite" aria-atomic="true">
  Слайд {carousel.selectedIndex + 1} з {carousel.snapCount}
</span>

@ Copyright 2026 Midstem. All rights reserved.

DocsMidstemGitHubLinkedin