Alpine.js Swipe Carousel

Touch Drag with Momentum (Inertia) & Precision Snap Slide Transition

A razor-light, dependency-free carousel for Alpine.js. Natural swipe transition, fluid inertia, and elegant pagination—designed to feel invisible and work everywhere.

  • Touch & mouse parity
  • Momentum / inertia
  • Snap slide transition
  • Pagination dots
Scroll to demo
SWIPE
Cinematic background
Momentum Swipe

Effortless Touch Drag

Natural inertia and precision snap slide transition. A luxury feel for modern interfaces.

Minimal. Precise. Beautiful.

A deliberately understated carousel: generous whitespace, balanced rhythm, and motion that never shouts.

Try the swipe
Motion Craft

Motion you can feel

Ease-out curves tuned for confidence. No borders, no noise—just balance and intent.

Let’s build yours

Tell us about your use case. We’ll reply shortly.

We reply in 24–48h. No spam.

Alpine.js Swipe Carousel — Details & Customization

A lightweight, dependency-free Alpine.js carousel/slider featuring real swipe transition (touch drag), smooth momentum/inertia, precise snap-to-slide animation, and elegant pagination dots. Built for speed, accessibility, and clean integration into any UI.

True Swipe Transition

Natural touch drag with subtle edge resistance and fluid throw.

Momentum (Inertia) & Snap

Velocity-based flicks settle into the nearest slide with smooth ease-out.

Keyboard & Pagination

Arrow keys and clickable dots provide precise, accessible navigation.

How it works

The carousel tracks pointer movement to calculate velocity and applies a controlled inertia throw toward the nearest slide. On release, it snaps to an index using an ease-out animation. Slides are <article> elements—semantic, flexible, and responsive by default.

Key behaviors

  • Touch & Mouse parity: pointer events ensure identical behavior across devices.
  • Responsive width: each <article> matches container width for full-bleed layouts.
  • A11y-friendly: focusable track, ARIA labels, and aria-current on active dots.
  • Extendable: add loop mode, wheel/trackpad support, or RTL with small adjustments.

Customize the feel

Setting What it controls Default Tuning tips
duration Snap animation time (ms). 350 Use 250–450ms. Shorter = snappier, longer = smoother.
threshold Drag distance (fraction of slide width) needed to change slide. 0.15 Raise to 0.25 for deliberate swipes; lower for lighter flicks.
throwK Converts release velocity to inertia distance. 220 160–260 is a sweet spot; higher = more “coast”.
maxThrowRatio Caps inertia throw as a fraction of slide width. 0.6 Set 0.4–0.8 to limit/allow longer flicks.
edgeResistance “Rubber band” feel on first/last slide drag. 0.35 Lower = looser, higher = tighter pullback.

Snappier UX

Set duration=250, threshold=0.2, throwK=180.

Smoother Glide

Set duration=420, throwK=240, edgeResistance=0.4.

Accidental-Swipe Proof

Set threshold=0.28 and a lower throwK (≈160).

Best practices

  • Use <article> for each slide—great semantics for content carousels.
  • Add descriptive aria-label to the track; set aria-current on the active dot.
  • Keep slide content lightweight; large images should be lazy-loaded.
  • Prefer requestAnimationFrame-driven transforms for silky performance.

Optional extensions

  • Loop mode: clone head/tail slides and remap indices on boundary hits.
  • Wheel/trackpad: listen to wheel and advance on horizontal delta.
  • RTL: apply dir="rtl" and invert translate math.
  • Fixed widths: remove auto-width and set explicit slide widths for multi-item views.
Markup
<div x-data="carousel()" x-init="init" class="relative overflow-hidden select-none" style="touch-action: pan-y;">
  <div class="flex" x-ref="track"
       :style="`transform: translate3d(${currentX()}px,0,0); will-change: transform;`"
       @pointerdown="onDown"
       @pointermove.window="onMove"
       @pointerup.window="onUp"
       @pointercancel.window="onUp"
       @keydown.arrow-right.prevent="goTo(index+1)"
       @keydown.arrow-left.prevent="goTo(index-1)"
       tabindex="0" role="region" aria-label="Alpine.js Swipe Carousel">

    <article class="p-8">...your content...</article>
    <article class="p-8">...your content...</article>
    <article class="p-8">...your content...</article>
  </div>

  <button class="absolute left-3 top-1/2 -translate-y-1/2 ..." @click="goTo(index-1)">‹</button>
  <button class="absolute right-3 top-1/2 -translate-y-1/2 ..." @click="goTo(index+1)">›</button>

  <nav x-show="total > 1" x-cloak class="mt-6 flex justify-center gap-2" aria-label="Carousel pagination">
    <template x-for="i in pages()" :key="i">
      <button @click="goTo(i)" :aria-current="index === i" class="h-2.5 rounded-full"
              :class="index === i ? 'w-6 bg-black' : 'w-2.5 bg-black/30 hover:bg-black/60'"></button>
    </template>
  </nav>
</div>
Script (Alpine component)
<script>
function carousel() {
  return {
    // --- Config (tweak freely) ---
    cfg: {
      duration: 350,          // ms, animation duration
      threshold: 0.15,        // fraction of slide width to change slide
      throwK: 220,            // inertia multiplier (px/ms * K)
      maxThrowRatio: 0.6,     // max fraction of slide width for inertia throw
      edgeResistance: 0.35     // resistance when dragging beyond edges
    },

    // --- State ---
    index: 0,
    slideW: 0,
    total: 0,
    isDragging: false,
    startX: 0,
    dragOffset: 0,
    baseX: 0,
    lastX: 0,
    lastT: 0,
    velocity: 0,
    animId: null,
    _ro: null,

    // --- Lifecycle ---
    init() {
      this.recalc();
      this._ro = new ResizeObserver(() => { this.recalc(); this.snapToIndex(false); });
      this._ro.observe(this.$el);
    },

    recalc() {
      this.slideW = this.$el.clientWidth;
      const items = this.$refs.track?.querySelectorAll(':scope > article') ?? [];
      this.total = items.length;
      items.forEach(el => { el.style.width = `${this.slideW}px`; el.classList.add('shrink-0'); });
      this.baseX = -this.index * this.slideW;
    },

    // --- Geometry ---
    currentX() {
      if (this.isDragging) {
        const atStart = this.index === 0 && this.dragOffset > 0;
        const atEnd = this.index === Math.max(0, this.total - 1) && this.dragOffset < 0;
        const edge = (atStart || atEnd) ? this.cfg.edgeResistance : 1;
        return this.baseX + this.dragOffset * edge;
      }
      if (this.animId) return this.baseX;
      return -this.index * this.slideW;
    },

    // --- Input ---
    onDown(e) {
      if (e.pointerType === 'mouse' && e.button !== 0) return;
      this.cancelAnim();
      this.isDragging = true;
      this.startX = e.clientX;
      this.dragOffset = 0;
      this.baseX = -this.index * this.slideW;
      this.lastX = e.clientX;
      this.lastT = performance.now();
      this.velocity = 0;
      e.preventDefault();
      e.target.setPointerCapture?.(e.pointerId);
    },

    onMove(e) {
      if (!this.isDragging) return;
      const x = e.clientX;
      this.dragOffset = x - this.startX;
      const now = performance.now();
      const dt = now - this.lastT;
      if (dt > 0) {
        const dx = x - this.lastX;
        const v = dx / dt; // px/ms
        this.velocity = this.velocity * 0.8 + v * 0.2;
        this.lastX = x;
        this.lastT = now;
      }
    },

    onUp() {
      if (!this.isDragging) return;
      this.isDragging = false;

      const MAX_THROW = this.slideW * this.cfg.maxThrowRatio;
      let throwDist = this.velocity * this.cfg.throwK;
      throwDist = Math.max(-MAX_THROW, Math.min(MAX_THROW, throwDist));

      const target = this.baseX + this.dragOffset + throwDist;
      let targetIndex = Math.round(-target / this.slideW);

      const dragDist = this.dragOffset + throwDist;
      if (Math.abs(dragDist) > this.slideW * this.cfg.threshold) {
        targetIndex = dragDist > 0 ? this.index - 1 : this.index + 1;
      }

      this.goTo(targetIndex, true);
    },

    // --- Navigation / Animation ---
    pages() { return Array.from({ length: this.total }, (_, i) => i); },

    goTo(i, animate = true) {
      const max = Math.max(0, this.total - 1);
      const clamped = Math.max(0, Math.min(max, i));
      const from = this.currentX();
      this.index = clamped;
      this.snapToIndex(animate, from);
    },

    snapToIndex(animate, fromX = null) {
      const from = fromX ?? this.currentX();
      const to = -this.index * this.slideW;
      if (!animate) { this.cancelAnim(); this.baseX = to; this.dragOffset = 0; this.velocity = 0; return; }
      this.animateTo(from, to, this.cfg.duration);
    },

    animateTo(from, to, duration) {
      this.cancelAnim();
      const start = performance.now();
      const easeOutCubic = t => 1 - Math.pow(1 - t, 3);
      const tick = (now) => {
        const t = Math.min(1, (now - start) / duration);
        this.baseX = from + (to - from) * easeOutCubic(t);
        this.dragOffset = 0;
        if (t < 1) this.animId = requestAnimationFrame(tick);
        else { this.animId = null; this.baseX = to; }
      };
      this.animId = requestAnimationFrame(tick);
    },

    cancelAnim() { if (this.animId) cancelAnimationFrame(this.animId); this.animId = null; }
  };
}
</script>

Made with ❤️ in Stockholm 🇸🇪 by Zaltsman Media