SwipiDocumentation

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.

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

Stylesheet
.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: hidden on the viewport clips the track; touch-action: pan-y lets a vertical swipe scroll the page while a horizontal one drags the carousel.
  • display: flex on 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 the 12px pair — the slide's padding-left with the track's matching negative margin-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.

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

@ Copyright 2026 Midstem. All rights reserved.

DocsMidstemGitHubLinkedin