Rejouice — loading0

Drag inertia gallery

A horizontal gallery you can throw with the pointer, which keeps moving and decelerates naturally.

Grade: ARuntimes: cssStatus: code-only

Documented, no live demo yet — the code below is complete.

Intent

Direct manipulation is the most legible interaction there is — you grabbed it, it moved. Inertia is what makes it feel like an object rather than a slider, and the deceleration curve is the entire personality of the component.

Implementation

Everything works with the script removed — the enhancement is drag and momentum, not the scrolling itself.

Dependencies: none

html-css-js
<div class="drag" data-drag tabindex="0" role="region" aria-label="Gallery">
  <figure class="drag__item"></figure>
  <figure class="drag__item"></figure>
</div>

<style>
.drag {
  display: flex;
  gap: var(--grid-gap);
  padding-inline: var(--gutter);
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
}

.drag::-webkit-scrollbar { display: none; }

.drag__item { flex: 0 0 clamp(16rem, 30vw, 28rem); margin: 0; }

.drag[data-dragging="true"] { cursor: grabbing; user-select: none; }
</style>

<script type="module">
for (const rail of document.querySelectorAll("[data-drag]")) {
  let startX = 0;
  let startScroll = 0;
  let velocity = 0;
  let lastX = 0;
  let raf = 0;

  rail.addEventListener("pointerdown", (event) => {
    if (event.pointerType === "touch") return; // native touch scrolling is better
    cancelAnimationFrame(raf);
    rail.setPointerCapture(event.pointerId);
    rail.dataset.dragging = "true";
    startX = lastX = event.clientX;
    startScroll = rail.scrollLeft;
    velocity = 0;
  });

  rail.addEventListener("pointermove", (event) => {
    if (rail.dataset.dragging !== "true") return;
    rail.scrollLeft = startScroll - (event.clientX - startX);
    velocity = lastX - event.clientX;
    lastX = event.clientX;
  });

  const release = () => {
    if (rail.dataset.dragging !== "true") return;
    rail.dataset.dragging = "false";

    const glide = () => {
      velocity *= 0.94;
      if (Math.abs(velocity) < 0.4) return;
      rail.scrollLeft += velocity;
      raf = requestAnimationFrame(glide);
    };
    raf = requestAnimationFrame(glide);
  };

  rail.addEventListener("pointerup", release);
  rail.addEventListener("pointercancel", release);

  // Any competing input wins immediately.
  rail.addEventListener("wheel", () => cancelAnimationFrame(raf), { passive: true });
  rail.addEventListener("keydown", () => cancelAnimationFrame(raf));
}
</script>

Use it when

  • Image galleries, testimonial rows, logo walls.

Avoid it when

  • Content that must be read in order, or where a specific item needs to be reachable directly.
  • As the only way to move through the set — always keep native scroll working underneath.

Accessibility

  • Build on a real `overflow-x: auto` element so keyboard arrow keys, `Home`/`End`, and screen-reader scroll all keep working for free.
  • Cancel the momentum loop the moment a keyboard or wheel interaction starts, or the two inputs fight.
  • Do not call `preventDefault` on `pointerdown` — it breaks focus and text selection outside the gallery.

Performance

  • Drive `scrollLeft`, not a transform, so the browser's own scroll optimisations apply and the scrollbar stays truthful.
  • Use pointer capture so a fast drag that leaves the element does not strand the gesture.
  • Stop the rAF loop at the cutoff instead of letting it idle forever.

Knobs

friction
Velocity multiplier per frame after release. Default: 0.94
cutoff
Velocity below which the loop stops. Default: 0.4 px/frame

Composition

Instruction for Claude Code

Build a drag-inertia gallery on top of a native overflow-x: auto flex rail with a tabindex, role=region, and an aria-label — the scrolling must work with JavaScript disabled. Add pointerdown/pointermove/pointerup handlers that use setPointerCapture and drive scrollLeft (not a transform), skipping pointerType 'touch' so native touch scrolling is untouched. On release, run a rAF momentum loop multiplying velocity by 0.94 and stopping below 0.4px per frame. Cancel the loop on wheel and keydown. Do not preventDefault on pointerdown.