Documentation

Looping a carousel that can’t be looped

scrollLeft has a hard ceiling. The Also Shipped carousel needed to scroll past it anyway — so it built somewhere further to go.

Field notes on jakerochford.com — the agent’s account of a bug it hit building the Also Shipped carousel, written up with Jake.

Background

New to drag-to-scroll carousels? Start here.

A horizontally-scrolling carousel is just a row of elements sitting inside a container with overflow-x: auto — the browser already knows how to scroll it, no library required. scrollLeft is the one number that describes where that scroll currently sits: 0 at the very start, and up toscrollWidth - clientWidth at the very end. Set it to anything outside that range and the browser just clamps it back in — that ceiling is the whole subject of this page. scroll-snap-type: x mandatory, set on the same container, is what makes a drag settle neatly on a slide boundary instead of stopping wherever the pointer happened to let go.

Every carousel on this site — What I Do’s service cards, and now Also Shipped’s project cards — shares one module, src/scripts/carousel.ts. It handles the part browsers don’t give you for free: dragging with a mouse. Touch already scrolls a row like this natively, but desktop browsers don’t turn a mouse drag into a scroll on their own, so the module adds that — track the pointer, move scrollLeft to match, then ease the strip to whichever slide the drag ended nearest.

That was enough for What I Do, where dragging past the last card just stops — a reasonable default. But once Also Shipped’s real content landed (four projects, each with five or six images and videos), a real question followed: dragging past the last image should keep scrubbing into the first one, not just snap there. It should feel like the strip never ends.

The intuition

Try the carousel below first. Drag it — with a mouse — past the last slide.

It stops dead at 4. Which makes sense — there’s nothing after it. scrollLeft is a real, physically bounded number, and the browser will not move it past the strip’s actual scrollable width no matter what value your code tries to hand it. You could setstrip.scrollLeft = 99999 and it would land in exactly the same place: clamped at the edge. Making the loop “feel infinite” isn’t a bigger-number problem. It’s a not-enough-content problem — there’s genuinely nothing there to scroll into.

So give it something. Clone the first slide, and drop that clone after the last one. Now there’s real, physical, scrollable content past the end — it just happens to be pixel-identical to slide 1. Do the same in reverse at the front, with a clone of the last slide. The strip goes from four elements to six:

Six DOM nodes, four real indices. The dashed boxes are the only new thing — actual elements with actual width, which is the only reason there’s anywhere left to scroll.

A drag can now scrub straight off the end of slide 4 and onto that trailing clone — same motion, same easing, nothing special happens. The moment it settles there, though, something has to happen: the outside world (the dots, anything tracking “which slide is active”) should never learn a clone exists. So the instant the strip stops on it, the code silently re-points scrollLeft at the real slide 1, sitting in the exact same visual position. Because the clone and the real slide are identical, nothing appears to move. The swap is imperceptible. Try it below — same drag, same everything, except this time it doesn’t stop:

Both demos above are the real createCarousel from this site’s owncarousel.ts, imported unmodified — the only difference between them is one flag,loop: true.

Reading the code

Setting up the clones

createCarousel only builds the phantoms when asked. A trailPhantomis a clone of the first real item, appended after the last one; a leadPhantomis a clone of the last item, inserted before the first:

if (loop) {
	trailPhantom = items[0].cloneNode(true) as HTMLElement;
	markAsPhantom(trailPhantom);
	strip.appendChild(trailPhantom);

	leadPhantom = items[items.length - 1].cloneNode(true) as HTMLElement;
	markAsPhantom(leadPhantom);
	strip.insertBefore(leadPhantom, items[0]);
}

cloneNode never copies event listeners, so a phantom starts inert — but it does copy attributes, including anything that made the original slide keyboard-focusable or announced to a screen reader. markAsPhantom strips that back off — role, tabindex, aria-label, and the data-slide-indexattribute this page comes back to below — and walks the clone’s own descendants (a video slide’s play button, say) to pull them out of the tab order too. A phantom is real DOM, but it should be invisible to everything except the scrollbar.

Two different numbers for “which slide”

With two extra nodes in the strip, “the third element in the DOM” and “slide index 2” stop being the same number. The module keeps them apart deliberately — everything outside it only ever deals in the second kind:

const allSlides = leadPhantom && trailPhantom ? [leadPhantom, ...items, trailPhantom] : items;
const domOffset = leadPhantom ? 1 : 0;

function toRealIndex(domPos: number): number {
	if (!loop) return domPos;
	if (domPos <= 0) return items.length - 1; // lead phantom
	if (domPos >= allSlides.length - 1) return 0; // trail phantom
	return domPos - domOffset;
}

function toDomPos(realIndex: number): number {
	return loop ? realIndex + domOffset : realIndex;
}

A caller asking to go to slide 0 gets DOM position 1 (past the lead phantom sitting at 0). A drag that lands on DOM position 0 is reported back as real index items.length - 1— the last slide, exactly as it should read, since visually that’s what’s showing.

The invisible swap

Once a drag settles, one check runs: did it land on a phantom? If so, jump — instantly, with snapping turned off for the one frame it takes — to the real slide occupying that same visual spot:

function correctIfOnPhantom(domPos: number) {
	if (!isPhantomDomPos(domPos)) return;
	const realIndex = toRealIndex(domPos);
	const correctedDomPos = toDomPos(realIndex);
	const prevSnap = strip.style.scrollSnapType;
	strip.style.scrollSnapType = 'none';
	strip.scrollLeft = strip.scrollLeft + slideOffsets()[correctedDomPos];
	strip.style.scrollSnapType = prevSnap;
}

This runs after every mouse-drag release, and — because a plain wheel or trackpad scroll can also snap onto a phantom without ever touching the drag handlers — after scrolling settles generally, on a short debounce. The reader never notices either path, for the same reason the whole trick works at all: a phantom and its real counterpart are pixel-identical.

A bug: landing on slide 2 instead of slide 1

The first working version of this loaded Also Shipped’s carousels sitting on the secondslide, not the first — the lead phantom’s clone-of-the-last-slide was visible on load instead. The fix ended up being one character, but the reasoning behind it is worth keeping:

if (loop) {
	strip.scrollLeft = strip.scrollLeft + slideOffsets()[toDomPos(0)];
}

slideOffsets() always returns deltas from wherever scrollLeftalready is — every other call site in the module adds it to the current value, never assigns it directly. The original version of this one line broke that pattern and assigned instead, which happened to work when scrollLeft started at exactly 0 — except it doesn’t reliably start at 0. Some browsers apply scroll anchoring: when content is inserted before the current scroll position (the lead phantom, inserted right before slide 1, is exactly that), the browser nudges scrollLeft on its own to keep whatever was visually on screen still on screen. By the time this line runs, “current” isn’t reliably zero anymore — so the fix was just making this line consistent with every other offset calculation in the file: add, don’t assign.

A bug: the lightbox counted the clones too

Also Shipped’s lightbox rebuilds its own strip by cloning whatever slides are currently in a project card. The first version of that query picked up all eight DOM children — six real images plus the two loop phantoms — and opened on whichever one the phantom math happened to land on, with two extra, silently-broken dots in the nav. The fix leaned on the same attributemarkAsPhantom already strips:

// [data-slide-index] excludes the inline carousel's own loop
// phantoms (carousel.ts strips that attribute specifically so
// real slides stay distinguishable from their clones) — without
// it this picks up all 8 DOM children (6 real + 2 phantoms), not
// the 6 real slides, which is exactly the extra-dots/wrong-
// starting-slide bug.
const sourceSlides = [...card.querySelectorAll<HTMLElement>('.ship-carousel__slide[data-slide-index]')];

Nothing about this bug lived inside carousel.ts at all — the module did exactly what it promised. It surfaced one layer up, in code that shared a CSS class with the phantoms without knowing they existed. That’s the actual cost of the technique: every future consumer of a looping strip has to remember phantoms are in there, sharing selectors with the real thing, unless it specifically asks for [data-slide-index] the way the phantoms themselves were told to give up.

One honest limit, straight from the module’s own comment: this only loops a mouse drag. Touch scrolling on mobile never runs through createCarousel at all — it’s native browser scroll-snap, full stop — so a real swipe past the last image on a phone still just stops. Fixing that would mean either reimplementing the whole phantom-and-correction dance on top of native touch scrolling, or deciding a mobile carousel doesn’t need to loop the same way a desktop drag does. Neither happened yet. It’s on the list, not solved — which is a fine place to end a page about a trick that only works because there was somewhere further to go.