SwipiDocumentation

Guides

Vertical carousel

One option turns the carousel on its side — and the four CSS declarations that follow it.

One option

axis: 'y' turns the carousel on its side. Slides are stacked instead of laid out in a row, the track moves up and down, and a swipe up or down is what drives it.

React
const [carouselRef, carousel] = useSwipiCarousel({ axis: 'y' })

Your HTML structure does not change at all — same viewport, same track, same slides. What changes are the styles applied to them, and it is a handful of declarations.

The styles that change

  • The viewport needs a height. A horizontal carousel takes its width from the page, but nothing gives it a height — so pick one, in pixels, in vh, whatever suits the layout.
  • The track becomes a column that fills the viewport: flex-direction: column with height: 100%, or flex-col h-full in Tailwind. That is what the calc(100% / 2) — the basis-1/2 — on the slide is measured against, so two slides are visible at a time here.
  • The gap moves from the sides to the top: padding-top on the slide with the matching negative margin-top on the track — pt-3 and -mt-3 instead of pl-3 and -ml-3.
  • touch-action: pan-x instead of pan-y — the carousel now takes the vertical swipe, so the horizontal one is what gets handed back to the page.
  • min-height: 0 on the slide instead of min-width: 0, so a tall slide cannot push past the size the basis gave it.
.carousel__viewport {
  height: 360px;
  overflow: hidden;
  touch-action: pan-x;
}

.carousel__track {
  display: flex;
  flex-direction: column;
  height: 100%;
  margin-top: -12px;
  user-select: none;
}

.carousel__slide {
  box-sizing: border-box;
  flex: 0 0 calc(100% / 2);
  min-height: 0;
  padding-top: 12px;
}

What stays the same

Everything else. Arrows, dots, loop, autoplay, dragFree, scrollTo, selectedIndex — same options, same state, same methods. scrollNext simply means down now instead of right.

  • slideWidth keeps its name but sizes a slide along the axis, so on a vertical carousel it sets a height. Same for spaceBetween and the vertical gap.
  • The gesture lock flips with the axis: a swipe that starts more horizontal than vertical is released back to the page instead of moving the carousel.
  • Keyboard support is yours either way — the natural pair here is up and down.
React
const handleKeyDown = (event) => {
  if (event.key === 'ArrowUp') carousel.scrollPrev()
  if (event.key === 'ArrowDown') carousel.scrollNext()
}

Switching the axis at runtime

axis is a plain option, so it can come from state and change while the carousel is alive — a carousel that is a row on desktop and a column on a phone, or one the reader flips themselves. Hand it a new value and the carousel re-measures, moves the track onto the other axis and stays on the slide it was showing. Options are read on every render, so a piece of state is all it takes.

  • Swap the classes along with it. The engine moves the track; the direction the slides are laid out in is still your CSS, the same way Responsive swaps the number of visible slides.
  • Nothing remounts and no state is lost — selectedIndex survives the switch, so dots and arrows keep pointing at the same slide.
  • Reading the breakpoint is yours: a matchMedia listener, a container query, a button — whatever already decides the rest of the layout.
Slide 1 of 0

01

02

03

04

05

The demo above is that snippet, with dots added so the switch has something to prove: pick a slide, flip the axis, and the same slide is still the selected one. The preview runs the React build of it — the code follows the framework you picked.

A button is the easy case. The one you probably want is the breakpoint — a column on a phone, a row from lg up. Read the media query into state, hand the result to axis, and let the responsive variants do the rest: the styles are the same lg: prefixes you would write anyway, and the carousel only ever needs to know which axis it is on.

Slide 1 of 0

01

02

03

04

05

(min-width: 1024px) does not match — running with axis: 'y'

That demo is live — narrow the window past 1024px and the carousel stands up on its own, keeping the slide you were on. The line under it is the query it is watching.

Markup
Styles
import { useSwipiCarousel } from '@midstem/swipi-react'

export const Carousel = ({ items }) => {
  const [carouselRef, carousel] = useSwipiCarousel({ axis: 'y', loop: true })

  return (
    <>
      <div className="overflow-hidden touch-pan-x h-[360px]" ref={carouselRef}>
        <div className="flex flex-col h-full -mt-3 cursor-grab select-none active:cursor-grabbing">
          {items.map((item) => (
            <div className="min-h-0 shrink-0 grow-0 basis-1/2 pt-3" 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>
    </>
  )
}

The playground has the axis among its options too, next to every other one — flip it there to try a vertical carousel against your own slide count, gap and speed.

@ Copyright 2026 Midstem. All rights reserved.

DocsMidstemGitHubLinkedin