Guides
Dots
Pagination rendered from snapCount and selectedIndex, plus a sliding indicator that moves between them.
One dot per snap
Dots are rendered from snapCount, not from the length of your array. The carousel stops once the remaining slides fit the viewport, so five half-width slides give four snaps — and four dots.
<nav className="carousel__dots">
{Array.from({ length: carousel.snapCount }, (_, index) => (
<button
type="button"
className="carousel__dot"
key={index}
aria-label={`Go to slide ${index + 1}`}
aria-current={index === carousel.selectedIndex}
onClick={() => carousel.scrollTo(index)}
/>
))}
</nav>.carousel__dots {
display: flex;
align-items: center;
gap: 10px;
}
.carousel__dot {
height: 12px;
width: 12px;
padding: 0;
background-color: #d2d2d2;
border: none;
border-radius: 50%;
cursor: pointer;
}
.carousel__dot[aria-current='true'] {
background-color: #b70808;
}A sliding indicator
Instead of recolouring the active dot, one element can travel between them. Pass the index down as a custom property and let CSS do the arithmetic — no per-dot measurement, and it animates on the compositor.
<span
className="carousel__indicator"
aria-hidden="true"
style={{ '--index': carousel.selectedIndex }}
/>.carousel__dots {
position: relative;
}
.carousel__indicator {
position: absolute;
left: 0;
height: 12px;
width: 12px;
background-color: #b70808;
border-radius: 50%;
/* dot size + gap */
transform: translateX(calc(var(--index) * 22px));
transition: transform 300ms cubic-bezier(0.25, 1, 0.5, 1);
pointer-events: none;
}The step is the dot size plus the gap. Keep the indicator aria-hidden and pointer-events: none: it is decoration sitting over the buttons that do the work.
The whole carousel
Dots, a sliding indicator and arrows around a one-slide carousel, in both styling flavours.
import { useSwipiCarousel } from '@midstem/swipi-react'
export const Carousel = ({ items }) => {
const [carouselRef, carousel] = useSwipiCarousel()
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">
<span
className="pointer-events-none absolute left-0 h-3 w-3 bg-[#b70808] rounded-full translate-x-[calc(var(--index)_*_22px)] transition-transform duration-[300ms] ease-[cubic-bezier(0.25,_1,_0.5,_1)]"
style={{ '--index': carousel.selectedIndex }}
/>
{Array.from({ length: carousel.snapCount }, (_, index) => (
<button
type="button"
className="h-3 w-3 p-0 bg-[#d2d2d2] border-none rounded-full cursor-pointer"
key={index}
data-active={index === carousel.selectedIndex}
onClick={() => carousel.scrollTo(index)}
/>
))}
</nav>
</>
)
}Dots and the first paint
snapCount is 0 until the slides are measured, which happens once the viewport is attached. Dots driven by it therefore appear a frame after the first paint rather than in the server output — render them from your own data if you need them in the HTML, as Server rendering explains.
