ramka

Why native scrolling beats transform carousels

Swipe through photos in your phone's gallery app and they stick to your finger. Swipe through most web lightboxes and something is off. You probably can't name it, but your thumb knows. This post is about what your thumb knows.

The footer of the ramka landing page jokes that proceeds fund the ongoing fight against scroll emulation. This is the serious version of that joke. It's the story of a decision most carousel and lightbox libraries made years ago — one chapter of the lightbox's twenty-year story — what it costs, and why ramka's slides are built on a boring real scroller instead.

The short answer, for the impatient: native scrolling is more resilient than a transform-driven JavaScript carousel because browsers can process eligible scrolls on a compositor or dedicated scrolling thread, without waiting for main-thread JavaScript. And it keeps each platform's own momentum, overscroll and input handling instead of re-creating them in an animation loop. The rest of this post is the evidence.

Let's start with the demo, because the demo is the whole argument.

main thread
worst stall: 0 ms
Native scroll — swipe or scroll it
Person standing in a sunlit meadow at golden hourSeashells scattered along the shorelineCurved building facade in black and whiteAirplane crossing a purple sky above the cloudsFog rolling over dark hillsAerial view of a road entering twin forest tunnelsBride seen through the windows of a vintage car
Transform carousel — drag or swipe it
Person standing in a sunlit meadow at golden hourSeashells scattered along the shorelineCurved building facade in black and whiteAirplane crossing a purple sky above the cloudsFog rolling over dark hillsAerial view of a road entering twin forest tunnelsBride seen through the windows of a vintage car
Both strips are identical DOM. Set the load to Slammed, then flick each one. The native strip keeps scrolling because the browser scrolls it off the main thread. The transform carousel is driven by pointer events and requestAnimationFrame, so it freezes with everything else.

Browsers scroll on a different thread

Scrolling is the single interaction browser engineers have spent the most years protecting. In Chromium's architecture, eligible scrolls run on the compositor thread, so they don't have to wait for JavaScript. The Chrome team's own RenderingNG docs hold it up as their flagship example of performance isolation: even on pages full of slow JavaScript, scrolling stays smooth, because it doesn't have to ask the main thread for permission to move.

That's what you saw in the demo above. The busy-wait blocks React, blocks timers, blocks requestAnimationFrame, blocks everything you can write in JavaScript. The native strip doesn't care. The browser keeps rasterized tiles of that scroller cached on the GPU, and moving them is a matter of changing an offset on a thread your code cannot touch.

Firefox and Safari have their own versions of the same design: Gecko documents its asynchronous panning and zooming architecture, and WebKit runs a dedicated scrolling thread. This is not a Chrome trick, it's fifteen years of engine work across every vendor, and every page gets it for free. The word "eligible" is doing honest work, though: a page can forfeit the fast path. Blocking touch or wheel listeners in the wrong place, or effects that make scrolling depend on layout, drag it back to the main thread. Which brings us to what carousels do.

What a transform carousel opts into

Most JavaScript carousels and image sliders, and most lightbox swipe implementations, don't scroll. They listen to pointer events, translate a flex row with transform, and when your finger lifts, they run their own physics in a requestAnimationFrame loop:

Scroll emulation, the standard recipe
// The heart of nearly every JS carousel, simplified.
el.addEventListener('pointermove', (e) => {
  offset += e.clientX - lastX;
  lastX = e.clientX;
  track.style.transform = `translateX(${offset}px)`;
});

el.addEventListener('pointerup', () => {
  requestAnimationFrame(function fling() {
    velocity *= 0.95; // the reverse-engineered iOS constant
    offset += velocity;
    track.style.transform = `translateX(${offset}px)`;
    if (Math.abs(velocity) > 0.1) requestAnimationFrame(fling);
  });
});

There's nothing incompetent about this code, and the problem is not the transform property. A committed transform is composited; a CSS transition or Web Animations fling that JavaScript kicks off once can even keep running while the main thread stalls. The problem is who owns the gesture: in the recipe above, JavaScript re-decides the position on every frame, so every frame of the drag and the fling is a main-thread callback, scheduled between whatever else the page is doing. On a lightbox, "whatever else" has terrible timing. The exact moment you start swiping is the moment the next full-resolution photo triggers fetches, state updates and rendering work, and much of that lands exactly where the fling's callbacks need to run.

And the cost starts before the first frame. Chrome's telemetry on Android found that around 80 percent of touch listeners that block scrolling never actually prevent it, yet the browser had to wait for them anyway. In 10 percent of those scrolls the waiting added over 100 milliseconds before anything moved, and in 1 percent it added more than half a second. When Chrome 56 started treating root touch listeners as passive by default, the slowest 1 percent of scroll starts across the Android web dropped from just over 400 milliseconds to about 250, a 38 percent cut from one intervention, with no site changing a line of code. Those are page-scrolling numbers, not carousel numbers, but they measure the same tax the carousel recipe pays by design: input that has to wait for JavaScript.

The physics you cannot fake

Say the main thread is quiet and every frame lands. The fling still has to feel right, and here the emulation chase gets genuinely funny. People have spent years reverse-engineering Apple's scroll feel: momentum decays by a factor of about 0.95 per frame at 60 frames per second, a 325 millisecond time constant. The rubber band at the edge follows a formula with a 0.55 coefficient that Apple never published at all: it was pulled out of UIScrollView and has been passed around in tweets and gists ever since. These are snapshots of one historical iOS implementation, and whole libraries exist to ship them to production as if they were the spec.

Flick it, then change the friction and flick again
Person standing in a sunlit meadow at golden hourSeashells scattered along the shorelineCurved building facade in black and whiteAirplane crossing a purple sky above the cloudsFog rolling over dark hillsAerial view of a road entering twin forest tunnelsBride seen through the windows of a vintage car
Velocity multiplied by this constant every frame is the whole momentum model. 0.95 is the value people have reverse-engineered out of iOS. Whatever you pick, it is one curve for every OS, every input device, and every future software update. Your users notice, even if they cannot say what is off.

Here's the trap: even a perfect clone is a snapshot of one platform's physics for one input device. A trackpad two-finger fling, a mouse wheel notch, and a thumb swipe feel completely different in a native scroller, because the OS tunes each one. The emulated version flattens them into a single curve, calibrated on the developer's own laptop. And when the OS updates its feel, native scrollers update with it. The magic numbers don't.

Overscroll makes the point visually. Below is the same ramka lightbox pulled past its first photo in Safari on an iPhone and in Chrome on an Android phone. iOS answers with its rubber band. Android answers with the stretch effect it introduced in Android 12. Neither behavior exists anywhere in ramka's code. The slides are a real scroller, so the edge belongs to the browser and the OS, and each platform speaks its own dialect. An emulated carousel ships one hardcoded rubber band to everyone and hopes iPhone physics feel right on a Pixel.

Same lightbox, zero platform-specific code. iOS rubber-bands, Android stretches.

What this costs, in numbers

Nobody publishes an A/B test titled "we replaced our transform carousel with native scroll," so I won't pretend one exists. What's published is the next best thing: what happens to businesses when they take work off the main thread.

redBus traced poor responsiveness on their search page partly to a scroll listener scheduling piles of main-thread work. That fix, together with the rest of their responsiveness effort, cut the page's Interaction to Next Paint by 72 percent, and the INP effort as a whole lifted sales by 7 percent. QuintoAndar cut INP by 80 percent across a broad main-thread cleanup and saw conversions grow 36 percent year over year. Trendyol halved INP by breaking up long main-thread tasks and measured a 1 percent click-through uplift in an A/B test. Main-thread contention is not an aesthetic concern. It shows up in revenue, and these companies did the accounting.

The pattern is old, too. When Pinterest rebuilt their mobile web experience around a leaner main thread in 2017, time on site went up 40 percent and core engagement 60 percent. None of these are "native scrolling" studies. They're all the same lesson: the main thread is where responsiveness goes to die, and the winning move is to give it less to do. A swipe engine is main thread work you can delete entirely.

Everything else real scroll gives you

The performance argument gets the headlines, but the freebies might matter more. A real scroller mirrors itself in right-to-left layouts, respects overscroll-behavior, shows a scrollbar when your design wants one, and keeps absorbing whatever input hardware ships next, all maintained by browser vendors, forever. A transform carousel reimplements each of those by hand or ships without them.

Two things you might expect on that list are pointedly not free, and pretending otherwise is how carousels get bad reputations. A scroll container is only keyboard-operable once something in it can take focus — Chrome didn't make plain scrollers keyboard-focusable by default until 2024 — which is why ramka's dialog takes focus itself and drives the scroller from Arrow, Home and End. And a screen reader gets no carousel semantics from an overflowing div; the naming and slide structure are still your work. That half of the story got its own post on lightbox accessibility, and so did the headless API shape that leaves every visible element yours to render.

My favorite freebie is one we noticed while profiling, and it's an observation, not documented behavior: on the iPhones we tested, Safari's Low Power Mode throttles the main thread — and with it requestAnimationFrame — to 30 frames per second, while compositor-driven scrolling keeps rendering at 60. A JavaScript fling literally halves its frame rate when the user's battery gets low. A native one doesn't notice.

Can CSS scroll snap replace a JavaScript carousel?

This is usually where the objections start — "but native scroll can't do X" — so let me go through the X's that made lightbox authors reach for emulation in the first place.

Snapping to slides. Solved in CSS: scroll snap has been cross-browser since 2019, scroll-snap-stop — which keeps a hard fling from skipping slides — since 2022, and the snap animation belongs to the browser, driven by the same machinery as the fling:

Native slide snapping
/* The scroll core of ramka's Slides, shown as CSS.
   (ramka applies these as inline styles.) */
[data-ramka-slides] {
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
}

[data-ramka-slide] {
  scroll-snap-align: center;
  scroll-snap-stop: always;
}

Tracking the active slide, navigating programmatically. Scroll position is state you can read and write. That CSS is genuinely the heart of it; what remains is bookkeeping, and ramka does it for you: it wires the active index, the Left arrowRight arrow keys, and next/previous buttons to the scroller rather than around it, and the swipe itself stays untouched:

Slides in ramka
<Lightbox.Slides aria-label="Photos">
  {items.map((item, i) => (
    <Lightbox.Slide key={item.id}>
      <Lightbox.Item index={i}>…</Lightbox.Item>
    </Lightbox.Slide>
  ))}
</Lightbox.Slides>

Gestures on top. The real reason lightboxes emulate: pinch to zoom and pull to dismiss need pointer tracking, and once you're tracking pointers it's tempting to own the horizontal axis too. ramka draws the line differently. Zoom and pull are their own engines, lazy-loaded and layered over the scroller with careful rules about who owns which axis, and the horizontal swipe is never intercepted. The browser keeps the part it does best.

Inputs you forgot to support. Here is a side effect of drag emulation that rarely makes the bug tracker: drag engines listen for pointer events, but a two-finger trackpad swipe emits wheel events, so in a surprising number of drag-driven carousels the trackpad does nothing at all. Patching it means adding a wheel handler, and that handler has to be non-passive so it can preventDefault the page’s own scrolling. A non-passive wheel listener forces the browser to wait for your JavaScript before it can scroll anything, which hauls wheel input back onto the main thread, the exact place this whole article has been trying to leave. A real scroller skips the dilemma: trackpad swipe, Shift plus mouse wheel, tilt wheels, touch, and pen input all work because the browser already routes scroll input to scrollers. Drag-first input has an accessibility cost too, and that half of the story is in the accessibility post.

Side by side, the two architectures divide the work like this:

CapabilityNative scrollingJavaScript momentum carousel
MomentumPlatform-providedApplication-defined
Main-thread stallsEligible scrolling continuesJS-driven frames stall
Trackpad and wheelNative scroll inputsRequires explicit handling
OverscrollPlatform and browser behaviorUsually simulated
Slide snappingCSS scroll snapCustom navigation and physics
RTL and semanticsNative foundation, still needs author workBuilt by hand

Feel it in a real lightbox

Here's the full thing: morphing open, native swipe between slides, pinch to zoom, pull to dismiss. Every horizontal movement you feel in the viewer is a real scroller.

If you're building a React image gallery or lightbox, this is what ramka is: composable lightbox primitives on a native scroller, with pinch to zoom and pull to dismiss layered on top. The React lightbox docs take about ten minutes, the gestures guide covers how the scroll, zoom, and dismiss layers coexist, and the image gallery presets are complete viewers to copy. ramka is free for open source under the GPL, and one payment covers proprietary use for your whole team. The fight against scroll emulation accepts contributions either way.