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
<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
Pairs with
Conflicts with
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.