
Effortless Touch Drag
Natural inertia and precision snap slide transition. A luxury feel for modern interfaces.
A razor-light, dependency-free carousel for Alpine.js. Natural swipe transition, fluid inertia, and elegant pagination—designed to feel invisible and work everywhere.
Natural inertia and precision snap slide transition. A luxury feel for modern interfaces.
A deliberately understated carousel: generous whitespace, balanced rhythm, and motion that never shouts.
Try the swipeEase-out curves tuned for confidence. No borders, no noise—just balance and intent.
Tell us about your use case. We’ll reply shortly.
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.
Natural touch drag with subtle edge resistance and fluid throw.
Velocity-based flicks settle into the nearest slide with smooth ease-out.
Arrow keys and clickable dots provide precise, accessible navigation.
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.
<article>
matches container width for full-bleed layouts.
aria-current
on active dots.
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. |
Set duration=250
, threshold=0.2
, throwK=180
.
Set duration=420
, throwK=240
, edgeResistance=0.4
.
Set threshold=0.28
and a lower throwK
(≈160).
<article>
for each slide—great semantics for content carousels.aria-label
to the track; set aria-current
on the active dot.wheel
and advance on horizontal delta.dir="rtl"
and invert translate math.<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>
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