Two carousels on this site, one carousel library. Embla, with the wheel-gestures plugin, about 64KB raw and 20KB gzipped.
Both carousels scroll sideways and snap. The browser does that:
.scroller { display: flex; overflow-x: auto; scroll-snap-type: x mandatory;}
.slide { flex: 0 0 100%; scroll-snap-align: start;}Touch drag, momentum, trackpad, keyboard, snapping. All of it free, from the platform. What a library adds on top is looping, a slide index for the dots, and autoplay. That's about sixty lines.
Don't track the index, read it
const read = () => setSelected(Math.round(el.scrollLeft / slideWidth(el)));
el.addEventListener('scroll', onScroll, { passive: true });Now it's right no matter how the reader moved. Drag, wheel, arrow buttons, a keyboard tab that scrolled a focused slide into view. Library state can drift from where the scroller actually is. A derived index can't.
Slides also stay in normal flow, so they're in the server HTML. A library-driven track usually has to hydrate before it looks right.
What it costs you
Mouse drag. Scroll-snap on desktop answers to wheel and trackpad, and click-and-drag with a mouse does nothing. Touch is unaffected, and touch is where dragging a carousel is a real gesture anyway. Fine trade for me. If you need desktop drag, that's your reason to keep the library.
Post page with the cover gallery, production build:
| before | after | |
|---|---|---|
| Page JS | 1,377KB raw / 412KB gz | 1,066KB raw / 338KB gz |
Some of that is other work landing at the same time. The gallery's own share is the ~20KB that Embla stopped shipping.
The bug that ate the afternoon
The cover gallery opens fullscreen. Same component in two places, so the hook keeps the live node in state and its effects rebind when it moves:
const scrollerRef = useCallback((el: HTMLDivElement | null) => { setNode(el);}, []);Open it once, close it, and the arrows were dead. Dots too. Index frozen. Permanently, until reload.
AnimatePresence keeps the closing overlay mounted while it animates out. So for about half a second both scrollers exist. The inline one mounts and sets the node. Then the overlay finishes, unmounts, and its ref fires with null — wiping the node the live scroller had just set.
React 19 has the fix built in. A ref callback can return a cleanup, and that cleanup knows which element left:
const scrollerRef = useCallback((el: HTMLDivElement | null) => { if (!el) return;
setNode(el);
return () => setNode(prev => (prev === el ? null : prev));}, []);The if (!el) return matters as much as the cleanup. Ignore the bare null, and only clear when the element leaving is the one you're holding.
I burned an hour before that testing a fix that was already correct. An old next start still had the port, and the service worker was serving the previous build's chunks. Two layers of stale at once. If a fix you can see in the source does nothing, kill the port by PID and unregister the service worker before you touch the code again. (that's an advice)
The whole API
const { scrollerRef, selected, next, previous, scrollTo, stop } = useSnapCarousel({ autoplayMs: 4500 });Autoplay pauses on hover, only runs while the rail is on screen, and stops for good on the first pointerdown. The on-screen part is an IntersectionObserver, so someone who scrolls down late doesn't arrive to find it four slides in. A ResizeObserver re-aligns the current slide when the scroller resizes, which also fixes the position when it remounts into the fullscreen overlay.
It's in my starter kit now. That's the real test of deleting a dependency: I reach for it before I reach for npm install.
§Comments