UI Components Hard
Gesture Carousel
A touch-enabled carousel with momentum, snap-to-slide, and dot indicators. Swipe with velocity to advance multiple slides. No libraries.
Open in Lab
MCP
vanilla-js css
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f0f13;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.page {
width: 100%;
max-width: 520px;
display: flex;
flex-direction: column;
gap: 24px;
}
header {
text-align: center;
}
header h1 {
font-size: 22px;
font-weight: 700;
color: #fff;
}
header p {
font-size: 13px;
color: #666;
margin-top: 4px;
}
/* Carousel */
.carousel {
overflow: hidden;
border-radius: 20px;
touch-action: pan-y;
cursor: grab;
}
.carousel:active {
cursor: grabbing;
}
.carousel-track {
display: flex;
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
}
.carousel-track.dragging {
transition: none;
}
.slide {
min-width: 100%;
}
.slide-inner {
height: 280px;
border-radius: 20px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 32px;
position: relative;
overflow: hidden;
}
.s1 {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
.s2 {
background: linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%);
}
.s3 {
background: linear-gradient(135deg, #10b981 0%, #0ea5e9 100%);
}
.s4 {
background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
}
.s5 {
background: linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%);
}
.slide-tag {
display: inline-block;
padding: 4px 12px;
background: rgba(255, 255, 255, 0.2);
border-radius: 20px;
font-size: 11px;
font-weight: 700;
color: #fff;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 12px;
width: fit-content;
}
.slide-inner h2 {
font-size: 22px;
font-weight: 800;
color: #fff;
line-height: 1.2;
margin-bottom: 8px;
}
.slide-inner p {
font-size: 14px;
color: rgba(255, 255, 255, 0.75);
line-height: 1.5;
}
/* Controls */
.carousel-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.carousel-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.08);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.carousel-btn:hover {
background: rgba(255, 255, 255, 0.16);
}
.carousel-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.carousel-btn svg {
width: 18px;
height: 18px;
}
.dots {
display: flex;
gap: 6px;
align-items: center;
}
.dot {
width: 6px;
height: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.3);
transition: width 0.25s ease, background 0.25s ease;
border: none;
padding: 0;
cursor: pointer;
}
.dot.active {
width: 20px;
background: #fff;
}const track = document.getElementById("carouselTrack");
const dotsContainer = document.getElementById("dots");
const prevBtn = document.getElementById("prevBtn");
const nextBtn = document.getElementById("nextBtn");
const slides = track.querySelectorAll(".slide");
const total = slides.length;
let current = 0;
let startX = 0;
let startTime = 0;
let deltaX = 0;
let dragging = false;
// Build dots
slides.forEach((_, i) => {
const dot = document.createElement("button");
dot.className = `dot${i === 0 ? " active" : ""}`;
dot.setAttribute("role", "tab");
dot.setAttribute("aria-label", `Slide ${i + 1}`);
dot.addEventListener("click", () => goTo(i));
dotsContainer.appendChild(dot);
});
function updateDots(index) {
dotsContainer.querySelectorAll(".dot").forEach((d, i) => {
d.classList.toggle("active", i === index);
});
prevBtn.disabled = index === 0;
nextBtn.disabled = index === total - 1;
}
function goTo(index) {
if (index < 0 || index >= total) return;
current = index;
track.style.transform = `translateX(-${current * 100}%)`;
updateDots(current);
}
prevBtn.addEventListener("click", () => goTo(current - 1));
nextBtn.addEventListener("click", () => goTo(current + 1));
// Touch / mouse drag
const carousel = document.getElementById("carousel");
function onDragStart(x) {
startX = x;
startTime = Date.now();
deltaX = 0;
dragging = true;
track.classList.add("dragging");
}
function onDragMove(x) {
if (!dragging) return;
deltaX = x - startX;
const offset = -(current * 100) + (deltaX / carousel.offsetWidth) * 100;
track.style.transform = `translateX(${offset}%)`;
}
function onDragEnd() {
if (!dragging) return;
dragging = false;
track.classList.remove("dragging");
const velocity = Math.abs(deltaX) / (Date.now() - startTime); // px/ms
const threshold = carousel.offsetWidth * 0.25;
if (deltaX < -threshold || (velocity > 0.4 && deltaX < 0)) {
goTo(current + 1);
} else if (deltaX > threshold || (velocity > 0.4 && deltaX > 0)) {
goTo(current - 1);
} else {
goTo(current);
}
deltaX = 0;
}
// Touch events
carousel.addEventListener("touchstart", (e) => onDragStart(e.touches[0].clientX), {
passive: true,
});
carousel.addEventListener("touchmove", (e) => onDragMove(e.touches[0].clientX), { passive: true });
carousel.addEventListener("touchend", onDragEnd);
// Pointer events (mouse)
carousel.addEventListener("pointerdown", (e) => {
if (e.pointerType === "touch") return;
carousel.setPointerCapture(e.pointerId);
onDragStart(e.clientX);
});
carousel.addEventListener("pointermove", (e) => {
if (e.pointerType === "touch") return;
onDragMove(e.clientX);
});
carousel.addEventListener("pointerup", (e) => {
if (e.pointerType === "touch") return;
onDragEnd();
});
// Keyboard
document.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft") goTo(current - 1);
if (e.key === "ArrowRight") goTo(current + 1);
});
// Init
updateDots(0);<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="style.css" />
<title>Gesture Carousel</title>
</head>
<body>
<div class="page">
<header>
<h1>Gesture Carousel</h1>
<p>Swipe or use arrows to navigate</p>
</header>
<div class="carousel" id="carousel" aria-roledescription="carousel">
<div class="carousel-track" id="carouselTrack">
<div class="slide" role="group" aria-roledescription="slide" aria-label="1 of 5">
<div class="slide-inner s1">
<span class="slide-tag">Mobile UI</span>
<h2>Gesture-driven interfaces feel natural</h2>
<p>Build interactions that match how people hold and use their phones.</p>
</div>
</div>
<div class="slide" role="group" aria-roledescription="slide" aria-label="2 of 5">
<div class="slide-inner s2">
<span class="slide-tag">Performance</span>
<h2>GPU-composited transitions only</h2>
<p>Use transform and opacity — never top, left, or margin for animations.</p>
</div>
</div>
<div class="slide" role="group" aria-roledescription="slide" aria-label="3 of 5">
<div class="slide-inner s3">
<span class="slide-tag">Touch Events</span>
<h2>Velocity matters as much as distance</h2>
<p>A fast flick should advance the slide even if the drag distance is small.</p>
</div>
</div>
<div class="slide" role="group" aria-roledescription="slide" aria-label="4 of 5">
<div class="slide-inner s4">
<span class="slide-tag">Accessibility</span>
<h2>Never forget keyboard and pointer users</h2>
<p>Always pair swipe gestures with arrow buttons and keyboard support.</p>
</div>
</div>
<div class="slide" role="group" aria-roledescription="slide" aria-label="5 of 5">
<div class="slide-inner s5">
<span class="slide-tag">PWA</span>
<h2>Native-feeling apps on the web</h2>
<p>Progressive Web Apps can match native gestures without any framework.</p>
</div>
</div>
</div>
</div>
<div class="carousel-controls">
<button class="carousel-btn prev" id="prevBtn" aria-label="Previous slide">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<div class="dots" id="dots" role="tablist" aria-label="Slide indicators"></div>
<button class="carousel-btn next" id="nextBtn" aria-label="Next slide">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Gesture Carousel
A fully touch-enabled carousel with momentum physics. A fast swipe advances the slide even if it doesn’t cross the 25% width threshold — the velocity matters.
How it works
- Slides are laid out horizontally in a
flextrack touchstartandtouchendrecord timestamps to compute swipe velocity- If velocity exceeds 0.4px/ms the carousel advances regardless of drag distance
- Otherwise a 25% threshold governs whether it advances or snaps back
- CSS
transitionhandles the smooth snap animation
Features
- Dot indicator syncs with the current slide
- Prev / next arrow buttons for mouse/keyboard users
- Auto-advance with pause-on-hover (optional, disabled by default)
- Infinite looping via index clamping
When to use it
- Hero image sliders
- Onboarding step carousels
- Product image galleries on mobile