Aduok Code

A Draggable, Peeking Product Carousel with Vanilla JS

Introduction

This article builds a product carousel that behaves less like a slideshow and more like a stack of physical cards on a table. Only the center card is fully in focus; its neighbors are visible at the edges of the screen, slightly smaller and rotated, waiting their turn. Dragging moves the whole stack in real time under the pointer, and letting go snaps to the nearest card — or, on a fast flick, sails past it to the next one — with a duration chosen from how quickly you were dragging.

The whole thing rests on five ideas working together: a container narrower than its wrapper with overflow left visible, so neighboring cards physically poke into view — one continuous floating-point "track position" instead of a plain integer index, so every card can compute how far it is from center at any instant, including mid-drag — pointer events that move the track 1:1 with the finger or cursor while dragging, then hand off to a CSS transition once released — a small velocity sample taken on every pointermove, used both to decide whether a flick should skip a card and to pick how fast the snap should animate — and a rail element whose marker position is read directly from each card's data, so it always reflects something true about the card in focus rather than just acting as a page-number dot.

What you will learn

How to let neighboring slides peek past a carousel's edges using a narrower inner container with overflow: visible — how to represent carousel position as a continuous float so per-card scale, rotation, and opacity can be derived every frame instead of just on card-change — how to implement grab-and-follow dragging with the Pointer Events API — how to turn drag speed into both a "skip a card" decision and a snap duration — and how to drive a progress rail from real per-item data instead of a generic dot indicator.

01
Part One
A Peeking Layout

Step 1 — Letting Neighbors Poke Into View

A typical carousel clips everything outside the current slide. To get the "next card is already waiting at the edge" look, the clipping has to happen one level higher than usual: the outer wrap spans the full page and clips at the page edge, but the carousel container itself is deliberately narrower than the wrap — around 60% of it — and left with overflow: visible. Any slide that sits one full container-width away from the active one lands squarely inside the wrap's empty margins, so it is visible without being clipped, right up until it would spill past the page boundary itself.

CSS
.carousel-wrap {
  width: 100%;
  overflow: hidden;           /* clips only at the page edge */
  display: flex;
  justify-content: center;
}

.carousel {
  width: min(60%, 380px);     /* narrower than the wrap on purpose */
  overflow: visible;          /* neighbors are allowed to poke out */
  touch-action: pan-y;
  cursor: grab;
}

.track {
  display: flex;
  will-change: transform;
}

.slide {
  flex: 0 0 100%;             /* each slide = one carousel width */
}
ElementWidth / OverflowRole
.carousel-wrap100% of page — hiddenOnly clips a slide once it would spill past the page
.carousel~60% of the wrap — visibleDeliberately narrow so neighbor slides show in the margins
.slide100% of .carouselOne card-width per slide, offset by the track transform
02
Part Two
Continuous Position & Per-Card Progress

Step 2 — Treating Position as a Float, Not an Index

If the carousel only ever tracked "the active index", every card's scale and rotation could only change at the moment a slide finishes — a hard cut. Instead, position is stored as a plain float, trackPosition, which is simply the active index while idle but drifts continuously while dragging (activeIndex 1.35, for instance, mid-swipe). Every render pass loops over all slides and computes progress = index - trackPosition, clamped to [-1, 1]. That single number drives everything: scale shrinks with distance, rotation leans away from center, and opacity fades toward the edges — so neighboring cards visibly and smoothly react to a drag before it even finishes.

JS
function render() {
  const containerWidth = carousel.clientWidth;
  track.style.transform = `translate3d(${-trackPosition * containerWidth}px, 0, 0)`;

  slides.forEach((slide, i) => {
    const progress = Math.max(-1, Math.min(1, i - trackPosition));
    const abs = Math.abs(progress);

    const scale = 1 - abs * 0.28;
    const rotate = progress * -14;
    const opacity = 1 - abs * 0.65;

    const card = slide.querySelector('.card');
    card.style.transform = `scale(${scale}) rotate(${rotate}deg)`;
    card.style.opacity = opacity.toFixed(2);
  });
}

Storing position as a float rather than an index is the single decision that makes dragging feel physical instead of stepped — every card's transform is just a pure function of "how far am I from trackPosition right now", so it never needs to know whether that distance is changing because of a drag, a snap animation, or a button click.

03
Part Three
Pointer Drag with Velocity Snap

Step 3 — Grab, Follow, Release

Dragging uses the Pointer Events API rather than separate mouse and touch handlers, so mouse, touch, and pen all go through one code path. On pointerdown the current trackPosition is captured as a starting point and CSS transitions are switched off, so every subsequent pointermove can update the transform directly with no lag. Each move also samples elapsed time and distance to build a rolling velocity estimate. On release, that velocity decides two things: whether the drag counts as a deliberate "flick" that should skip an extra card, and — win or lose that decision — how fast the final snap animates, so a fast flick resolves quickly and a slow, deliberate drag settles gently.

JS
carousel.addEventListener('pointerdown', (e) => {
  isDragging = true;
  startX = e.clientX;
  startTrackPosition = trackPosition;
  lastMoveTime = performance.now();
  lastMoveX = e.clientX;
  velocity = 0;
  setTransition(0); // no easing while actively dragging
  carousel.setPointerCapture(e.pointerId);
});

carousel.addEventListener('pointermove', (e) => {
  if (!isDragging) return;
  const containerWidth = carousel.clientWidth;
  const deltaX = e.clientX - startX;
  trackPosition = startTrackPosition - deltaX / containerWidth;

  const now = performance.now();
  const dt = now - lastMoveTime;
  if (dt > 0) velocity = (e.clientX - lastMoveX) / dt;
  lastMoveTime = now;
  lastMoveX = e.clientX;

  requestAnimationFrame(render);
});

function endDrag() {
  isDragging = false;

  // a fast-enough flick skips straight to the next/prev card
  let target = Math.round(trackPosition);
  if (Math.abs(velocity) > 0.4) {
    target = velocity < 0 ? Math.ceil(trackPosition) : Math.floor(trackPosition);
  }

  // faster drags get a shorter, snappier settle
  const speed = Math.max(200, Math.min(1000, 1000 - Math.abs(velocity) * 800));
  goTo(target, speed);
}
Why setPointerCapture matters

Calling setPointerCapture on pointerdown keeps every subsequent pointermove and pointerup routed to the carousel element even if the pointer strays outside its bounds mid-drag — without it, a fast swipe that briefly leaves the element would silently stop updating the transform.

04
Part Four
A Rail Driven by Real Data

Step 4 — Replacing Dots with Meaning

Dot indicators only ever answer "which slide number am I on". A more useful device is a rail whose marker position comes from an actual attribute of each item — in this build, a 0–1 position along a mild-to-hot gradient, stored per card alongside its name and price. Moving to a new card doesn't just move a dot to the next stop; it slides the marker to wherever that specific card's value sits on the scale, and swaps a text label to match, so the control doubles as a small piece of information about the collection itself.

JS
const ITEMS = [
  { name: 'Item A', heatLabel: 'Mild', heatPos: 0.08 },
  { name: 'Item B', heatLabel: 'Medium', heatPos: 0.42 },
  { name: 'Item C', heatLabel: 'Medium-Hot', heatPos: 0.55 },
  { name: 'Item D', heatLabel: 'Hot', heatPos: 0.92 },
];

function updateRail(activeIndex) {
  const active = ITEMS[activeIndex];
  marker.style.left = `${active.heatPos * 100}%`;
  label.style.left = `${active.heatPos * 100}%`;
  label.textContent = active.heatLabel;
}
05
Part Five
Real Photos with a Safe Fallback

Step 5 — Fetching Photos Without Risking a Broken Layout

Rather than shipping fixed image files, each card can pull a real photo at runtime from a small public image API and crop it into a circle with object-fit: cover. Because that fetch happens over the network, it is wrapped in a try/catch: on success, the returned list is shuffled and one photo is handed to each card; on any failure — offline, a slow response, an API outage — the code falls back to one fixed, known-good URL per card, so the carousel never renders with a missing image or shifts layout while it waits.

JS
async function fetchPhotos(count, fallbacks) {
  try {
    const res = await fetch('https://example-image-api.test/api/images/category/')
    const data = await res.json()
    const urls = Array.isArray(data) ? data : data.images || []
    if (urls.length >= count) {
      return urls.sort(() => Math.random() - 0.5).slice(0, count)
    }
  } catch (err) {
    console.warn('Photo fetch failed, using fallback images', err)
  }
  return fallbacks
}

The fallback list isn't an afterthought bolted on for error handling — it's written and tested as if the network call will always fail, then treated as a bonus whenever it succeeds. That ordering is what keeps a network-dependent visual from ever being the reason a page looks broken.

Tuning Reference

ConstantDefaultEffect
carousel width60% of wrapHow much of each neighbor peeks in from the edges
scale falloff0.28 per unit progressHow aggressively off-center cards shrink
rotation falloff14° per unit progressHow far off-center cards lean away from vertical
opacity falloff0.65 per unit progressHow much off-center cards fade
flick threshold0.4 px/msMinimum velocity treated as a deliberate flick
snap speed range200–1000msClamped duration range chosen from release velocity
transition easingcubic-bezier(0.22, 0.74, 0.46, 0.97)Shared ease for every animated property

Full Source Code

The complete carousel is a single self-contained HTML document — no build step, no external carousel library, just Pointer Events, CSS transforms, and a small fetch with a fallback. The layout, drag physics, rail, and image loading described above sit directly below the markup in the same file.

HTML — carousel.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/peeking-carousel.html

One HTML file, a dozen small functions, zero dependencies. The whole peeking-carousel effect — visible-overflow layout, continuous per-card progress, pointer drag with velocity-aware snapping, a data-driven rail, and a fetch-with-fallback for real photos — is well under 300 lines of vanilla JavaScript.

Want to see it in action?Watch the full build on YouTube