Guides
Viewport & Track
The three nested elements the carousel needs, what the binding does to them and the CSS contract behind the movement.
The structure
The binding goes on the viewport. The track is found as its only child, and the slides are the children of the track. That is the entire wiring — the hook never renders an element, never writes an attribute and never puts anything into your markup.
import { useSwipiCarousel } from '@midstem/swipi-react'
export const Carousel = ({ items }) => {
const [carouselRef] = useSwipiCarousel()
return (
<div className="carousel__viewport" ref={carouselRef}>
<div className="carousel__track">
{items.map((item) => (
<div className="carousel__slide" key={item.id}>
{item.title}
</div>
))}
</div>
</div>
)
}Because the listeners are attached to the viewport element directly, no handler of yours is overwritten: your own key and click handlers keep working exactly as written.
The required CSS
Three rules carry the geometry. Class names are yours — only the declarations matter, and they are the same in every framework.
.carousel__viewport {
overflow: hidden;
touch-action: pan-y;
}
.carousel__track {
display: flex;
margin-left: -12px;
cursor: grab;
user-select: none;
}
.carousel__track:active {
cursor: grabbing;
}
.carousel__slide {
box-sizing: border-box;
flex: 0 0 calc(100% / 2);
min-width: 0;
padding-left: 12px;
}overflow: hiddenon the viewport clips the track;touch-action: pan-ylets a vertical swipe scroll the page while a horizontal one drags the carousel.display: flexon the track lays the slides out in a row. The engine moves the track with a transform, so leave its own width alone.flex: 0 0 calc(100% / 2)on the slide sets how many slides are visible, and the12pxpair — the slide'spadding-leftwith the track's matching negativemargin-left— sets the space between them.
The same thing in Tailwind
In Tailwind the two rules are flex -ml-3 on the track and min-w-0 shrink-0 grow-0 basis-1/2 pl-3 on the slide — the classes shadcn's carousel puts on its own container and items, which is why its components run over this engine unchanged.
<div className="overflow-hidden touch-pan-y" ref={carouselRef}>
<div className="flex -ml-3 select-none cursor-grab active:cursor-grabbing">
{items.map((item) => (
<div
className="min-w-0 shrink-0 grow-0 basis-1/2 pl-3"
key={item.id}
>
{item.title}
</div>
))}
</div>
</div>How measuring works
Measuring follows the layout rather than your render cycle. The carousel re-measures when a slide changes size — an image that finishes loading, a webfont that swaps in, an accordion inside a slide that opens — and when slides are added to or removed from the track, none of which has to pass through a render. A re-render of the component around it, on the other hand, reads nothing from the DOM at all.
Measurements keep their fractions: widths that land between two pixels, as percentage widths usually do, are used as they are rather than rounded, so a loop stays seamless however many times it comes round.
