Swipi: Vanilla JS Carousel with Drag, Touch-swipe and Autoplay

Category: Javascript , Slider | August 25, 2026
Authormidstem
Last UpdateAugust 25, 2026
LicenseMIT
Views0 views
Swipi: Vanilla JS Carousel with Drag, Touch-swipe and Autoplay

Swipi is a headless JavaScript carousel engine that handles dragging, momentum, snapping, looping, and autoplay. The Vanilla JS package exposes the carousel through createSwipi() while your application defines the HTML, CSS, controls, and accessibility markup.

Note that swipi includes no stylesheet. It measures the dimensions produced by your CSS, which keeps slide widths, responsive layouts, spacing, arrows, dots, and visual states under application control.

React, Vue, Svelte, and Angular adapters use the same carousel engine. The implementation below uses the dependency-free @midstem/swipi package for plain JavaScript.

Features

  • Dependency-free Vanilla JavaScript.
  • Headless HTML and CSS model.
  • Mouse and touch dragging with momentum.
  • Snap-based and drag-free movement.
  • Infinite looping and autoplay.
  • Horizontal and vertical layouts.
  • Responsive slide sizing through CSS.
  • Programmatic navigation and observable carousel state.
  • Reduced-motion handling for Swipi track movement.

How To Use It

Browser

Import the ESM package directly from a CDN.

<script type="module">
import { createSwipi } from 'https://cdn.jsdelivr.net/npm/@midstem/[email protected]/+esm';
</script>

npm

You can also install the core package with NPM and import createSwipi into your JS.

npm install @midstem/swipi
import { createSwipi } from '@midstem/swipi';

Basic Usage

Swipi requires a viewport, one track inside that viewport, and slides as direct children of the track. The viewport clips the moving track. Slide widths determine how many items appear at once and where each snap position lands.

The example below uses two visible cards, a 12px gap, drag gestures, and previous/next buttons. subscribe() keeps the button states synchronized with carousel navigation.

<div class="demo-carousel">
  <div class="carousel-viewport">
    <div class="carousel-track">
      <article class="carousel-slide">Project One</article>
      <article class="carousel-slide">Project Two</article>
      <article class="carousel-slide">Project Three</article>
      <article class="carousel-slide">Project Four</article>
    </div>
  </div>
  <div class="carousel-controls">
    <button type="button" class="carousel-prev" aria-label="Previous slide">
      Previous
    </button>
    <button type="button" class="carousel-next" aria-label="Next slide">
      Next
    </button>
  </div>
</div>
<style>
.carousel-viewport {
  overflow: hidden;
  touch-action: pan-y;
}
.carousel-track {
  display: flex;
  margin-left: -12px;
  user-select: none;
}
.carousel-slide {
  box-sizing: border-box;
  flex: 0 0 50%;
  min-width: 0;
  padding-left: 12px;
}
.carousel-controls {
  display: flex;
  gap: 8px;
  margin-top: 16px;
}
</style>
<script type="module">
import { createSwipi } from 'https://cdn.jsdelivr.net/npm/@midstem/[email protected]/+esm';
const viewport = document.querySelector('.carousel-viewport');
const prevButton = document.querySelector('.carousel-prev');
const nextButton = document.querySelector('.carousel-next');
const carousel = createSwipi(viewport);
function updateControls() {
  const state = carousel.getSnapshot();
  prevButton.disabled = !state.canScrollPrev;
  nextButton.disabled = !state.canScrollNext;
}
prevButton.addEventListener('click', function () {
  carousel.scrollPrev();
});
nextButton.addEventListener('click', function () {
  carousel.scrollNext();
});
carousel.subscribe(updateControls);
updateControls();
</script>

Responsive Slide Counts

Swipi reads the rendered slide geometry. Media queries can change flex-basis, and the carousel recalculates its snap positions after the dimensions change.

A gap-only breakpoint needs extra attention. Changing slide padding alone can leave the measured slide box at the same width. Use spaceBetween for a gap that changes independently from the slide count.

This CSS displays three slides above 800px, two below 800px, and one below 580px.

.carousel-slide {
  box-sizing: border-box;
  flex: 0 0 calc(100% / 3);
  min-width: 0;
  padding-left: 24px;
}
@media (max-width: 800px) {
  .carousel-slide {
    flex-basis: 50%;
  }
}
@media (max-width: 580px) {
  .carousel-slide {
    flex-basis: 100%;
  }
}

Container Queries

Carousel sizing does not depend on viewport width. A component inside a resizable sidebar can use a container query for the same slide-sizing rule.

.carousel-wrapper {
  container-type: inline-size;
}
@container (max-width: 640px) {
  .carousel-slide {
    flex-basis: 100%;
  }
}

Looping and Autoplay

loop keeps navigation active across the complete slide set. Autoplay advances every autoplaySpeed milliseconds, and animationSpeed controls programmed movement between snaps.

Autoplay reaches the final snap and stops when loop is false. A carousel that moves automatically for more than five seconds should expose a visible pause control. respectReducedMotion removes Swipi’s track travel when the operating system requests reduced motion.

const carousel = createSwipi(viewport, {
  loop: true,
  autoplay: true,
  autoplaySpeed: 3500,
  animationSpeed: 500,
  respectReducedMotion: true
});

Drag-Free Scrolling

Normal dragging resolves to a snap position. dragFree: true retains momentum and permits the track to stop between snaps.

const carousel = createSwipi(viewport, {
  dragFree: true
});

Vertical Carousel

axis: 'y' changes the movement direction to vertical. The viewport needs an explicit height, the track uses a column layout, the gap moves to the vertical axis, and touch-action changes to pan-x.

const carousel = createSwipi(viewport, {
  axis: 'y'
});
.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 50%;
  min-height: 0;
  padding-top: 12px;
}

All Configuration Options

  • axis ('x' | 'y'): Sets horizontal or vertical movement. The default is 'x'.
  • loop (boolean): Keeps navigation moving through the slide set when enough content exists to overflow the viewport. The default is false.
  • dragFree (boolean): Keeps drag momentum and permits the track to rest between snap positions. The default is false.
  • autoplay (boolean): Advances the carousel automatically. The default is false.
  • autoplaySpeed (number): Sets the autoplay interval in milliseconds. The default is 4000.
  • animationSpeed (number): Sets the duration of programmed carousel movement in milliseconds. The default is 300.
  • respectReducedMotion (boolean): Watches prefers-reduced-motion and removes programmed track travel when reduced motion is active. The default is false.
  • startIndex (number): Sets the initial snap index. The default is 0.
  • slideWidth (number): Writes a pixel value to --swipi-slide-width. CSS must read the property before it affects layout.
  • spaceBetween (number): Writes a pixel value to --swipi-slide-gap and signals Swipi to measure the layout again.
  • onSelect (function): Receives the full navigable state whenever carousel state changes.
  • onChange (function): Receives the previous, current, and next indexes when the selected index changes.

onChange Event

Use onChange for captions, analytics, counters, or other code that should run only after the selected index changes.

const carousel = createSwipi(viewport, {
  onChange: function ({ prev, current, next }) {
    console.log('Previous:', prev);
    console.log('Current:', current);
    console.log('Next:', next);
  }
});

onSelect Event

Use onSelect when application UI depends on the complete navigable state.

const carousel = createSwipi(viewport, {
  onSelect: function (state) {
    console.log(state.selectedIndex);
    console.log(state.snapCount);
    console.log(state.canScrollPrev);
    console.log(state.canScrollNext);
  }
});

API Methods

The Vanilla JS instance exposes navigation, state, live configuration, measurement, synchronization, and cleanup methods.

// Move to the next snap.
carousel.scrollNext();
// Move to the previous snap.
carousel.scrollPrev();
// Move to snap index 3.
carousel.scrollTo(3);
// Read the current carousel state.
const state = carousel.getSnapshot();
// Run a listener after state changes.
// The returned function removes the subscription.
const unsubscribe = carousel.subscribe(updateControls);
// Change configuration on a live carousel.
carousel.update({
  autoplay: false,
  loop: true
});
// Measure the carousel after an unobserved layout change.
carousel.measure();
// Reapply the current transform after slide rendering changes.
carousel.sync();
// Remove listeners, observers, and carousel behavior.
carousel.destroy();

`resolveOptions()`

resolveOptions() is an exported helper for code that needs Swipi’s resolved defaults.

import {
  createSwipi,
  resolveOptions
} from '@midstem/swipi';
const settings = resolveOptions({
  loop: true,
  autoplay: true
});

Reading Carousel State

  • selectedIndex: Current snap index.
  • snapCount: Number of available snap positions.
  • slidesCount: Number of slides in the track.
  • hasOverflow: Reports whether the content extends beyond the viewport.
  • canScrollNext: Reports whether forward navigation is available.
  • canScrollPrev: Reports whether backward navigation is available.
const counter = document.querySelector('.carousel-counter');
function updateCounter() {
  const state = carousel.getSnapshot();
  counter.textContent =
    `${state.selectedIndex + 1} / ${state.snapCount}`;
}
carousel.subscribe(updateCounter);
updateCounter();

CSS Variables

slideWidth and spaceBetween write two optional custom properties onto the track. Swipi does not use these properties to calculate CSS itself. The stylesheet must read them.

The example below uses both values to define fixed slide content width and spacing.

  • --swipi-slide-width: Pixel value written by slideWidth.
  • --swipi-slide-gap: Pixel value written by spaceBetween.
const carousel = createSwipi(viewport, {
  slideWidth: 280,
  spaceBetween: 16
});
.carousel-track {
  display: flex;
  margin-left: calc(-1 * var(--swipi-slide-gap, 0px));
}
.carousel-slide {
  box-sizing: border-box;
  flex: 0 0 calc(
    var(--swipi-slide-width, 280px) +
    var(--swipi-slide-gap, 0px)
  );
  min-width: 0;
  padding-left: var(--swipi-slide-gap, 0px);
}

Keyboard Controls and Accessibility

Swipi creates no accessibility attributes or text. Application markup owns the carousel role, accessible name, slide labels, arrow labels, dot state, keyboard handling, and live-region announcement.

Custom fades, parallax effects, dot transitions, and other CSS animation need their own prefers-reduced-motion rules. respectReducedMotion controls Swipi’s track movement.

The example below adds viewport keyboard navigation and a live region.

<div
  class="carousel-viewport"
  tabindex="0"
  role="group"
  aria-roledescription="carousel"
  aria-label="Featured projects"
>
  <div class="carousel-track">
    <article
      class="carousel-slide"
      role="group"
      aria-roledescription="slide"
      aria-label="1 of 3"
    >
      Project One
    </article>
    <article
      class="carousel-slide"
      role="group"
      aria-roledescription="slide"
      aria-label="2 of 3"
    >
      Project Two
    </article>
    <article
      class="carousel-slide"
      role="group"
      aria-roledescription="slide"
      aria-label="3 of 3"
    >
      Project Three
    </article>
  </div>
</div>
<span class="carousel-status" aria-live="polite" aria-atomic="true"></span>
<script>
const status = document.querySelector('.carousel-status');
viewport.addEventListener('keydown', function (event) {
  if (event.key === 'ArrowLeft') {
    carousel.scrollPrev();
  }
  if (event.key === 'ArrowRight') {
    carousel.scrollNext();
  }
});
carousel.subscribe(function () {
  const state = carousel.getSnapshot();
  status.textContent =
    `Slide ${state.selectedIndex + 1} of ${state.snapCount}`;
});
</script>

Updating a Live Carousel

update() changes configuration on an existing Vanilla JS instance. A pause button can toggle the autoplay option while keeping the current carousel state.

const pauseButton = document.querySelector('.carousel-pause');
let autoplayEnabled = true;
pauseButton.addEventListener('click', function () {
  autoplayEnabled = !autoplayEnabled;
  carousel.update({
    autoplay: autoplayEnabled
  });
  pauseButton.textContent = autoplayEnabled ? 'Pause' : 'Play';
});

Framework Adapters

Plain JavaScript projects only need @midstem/swipi. Framework-specific packages wrap the same engine in APIs suited to each framework.

  • @midstem/swipi-react
  • @midstem/swipi-vue
  • @midstem/swipi-svelte
  • @midstem/swipi-angular

Alternatives

You Might Be Interested In:


Leave a Reply