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.
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, invh, whatever suits the layout. - The track becomes a column that fills the viewport:
flex-direction: columnwithheight: 100%, orflex-col h-fullin Tailwind. That is what thecalc(100% / 2)— thebasis-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-topon the slide with the matching negativemargin-topon the track —pt-3and-mt-3instead ofpl-3and-ml-3. touch-action: pan-xinstead ofpan-y— the carousel now takes the vertical swipe, so the horizontal one is what gets handed back to the page.min-height: 0on the slide instead ofmin-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.
slideWidthkeeps its name but sizes a slide along the axis, so on a vertical carousel it sets a height. Same forspaceBetweenand 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.
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 —
selectedIndexsurvives the switch, so dots and arrows keep pointing at the same slide. - Reading the breakpoint is yours: a
matchMedialistener, a container query, a button — whatever already decides the rest of the layout.
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.
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.
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.
