Skip to content

Rebuild ol-carousel on native scroll snapping - #13220

Merged
lokesh merged 9 commits into
internetarchive:masterfrom
lokesh:ol-carousel/refactor/native-scroll
Aug 14, 2026
Merged

Rebuild ol-carousel on native scroll snapping#13220
lokesh merged 9 commits into
internetarchive:masterfrom
lokesh:ol-carousel/refactor/native-scroll

Conversation

@lokesh

@lokesh lokesh commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Part of #13240 (Epic: UI Modernization — consistency, mobile, accessibility), under the Mobile pass workstream.

This is also a response to @cdrini's review on #13101 — "Is there a reason we're not using the new browser-native CSS scrolling/snapping features?" The answer turned out to be no, so here is the native version.

[refactor] Rebuilds <ol-carousel> on a native scroll container with CSS scroll snapping, replacing the transform-based track and its hand-rolled gesture and spring physics.

Low risk: <ol-carousel> still has no production consumer — it appears only on /developers/design#carousel. Nothing user-facing changes. The public API (attributes, page/totalPages, next/prev/goToPage, ol-carousel-page-change) is untouched.

How it feels different

Swiping is the operating system's, not ours. Momentum, acceleration, overscroll bounce and axis locking now come from the platform. They will match every other scrollable surface on the patron's device, on every device, and stay correct as the OS changes them.

Scrolling never fights the page. Vertical panning was already the browser's; now the horizontal axis is too, so there is no arbitration between them to get wrong.

A plain mouse wheel works. Previously only a two-finger trackpad swipe could move the rail — mouse users had to find the arrows.

Covers load when they are actually near. Lazy loading is driven by real intersection with the scrollport rather than page-index arithmetic.

Scrolling is smooth under load. Native scrolling runs on the compositor, so a rail decoding covers mid-swipe no longer competes with a requestAnimationFrame animation on the main thread.

Off-page books are reachable. They are no longer inert. Tabbing to one scrolls it into view, and screen readers, find-in-page and voice control can reach the whole rail.

The one deliberate loss: arrow-button travel uses the browser's smooth-scroll easing instead of our spring curve, and mouse click-drag is gone — as it is on native scrollers generally.

Technical

The viewport becomes the scroll container. Snap points are declared on the items — scroll-snap-align: start on every Nth, end on the last — so the browser owns landing, including the ragged final page. goToPage() calls scrollTo({left}) with no behavior, deferring to CSS scroll-behavior, which means one prefers-reduced-motion media query switches the whole component to instant.

Deleted: pointer/touch handlers, axis lock, velocity smoothing, spring animation, rubber-band, the page offset table, nearest-page search, wheel paging, drag-click suppression, index-based lazy loading. 349 JS lines, down from 425 on master (and from 591 on the #13101 branch).

Where browser support still needs custom code, checked against our browserslist:

Concern Handling
scrollend — Safari 26.2 only Feature-detected; debounced scroll fallback carries every iOS 18.x patron. Both paths tested.
scrollbar-width — Safari 18.2 Paired with a ::-webkit-scrollbar fallback.
Item widths Stays on ResizeObserver (Safari 13.1). Container queries would raise the floor to Safari 16 and fail hard — items lose their width — where scroll-behavior (15.4) and overscroll-behavior (16) merely degrade gracefully.
Page indicators ::scroll-marker would delete the whole tablist, but it is Chromium-only. Kept as-is.

Jest needed a transform block in package.json: .babelrc is not applied to node_modules, and lit ships ESM, so no existing test could import a real LitElement.

Testing

  1. make lit-components && docker compose restart web, then open /developers/design#carousel.
  2. Swipe the rails on a phone, or with a trackpad — momentum and overscroll should feel like any other scroller on the device.
  3. Scroll with a plain mouse wheel over a rail.
  4. Drag partway between pages and release — it should always land on a page boundary, never between.
  5. On the third rail (18 items), page to the end. The last book's right edge should sit flush with the viewport edge.
  6. Arrow buttons, page indicators, and ←/→/Home/End on the indicator bar.

npm run test:js — 37 new unit tests, 528 total passing.

Verified in the dev app on the 18-item rail (5 columns → pages of 5/5/5/3): snap points at 0/665/1352/1742, every arbitrary scroll position lands exactly on a page boundary, and the last item's right edge measures 0px from the viewport edge on the final page.

Two things I could not verify locally and would like a second pair of eyes on: arrow-click smooth-scroll feel in Safari, especially interrupting one click with another, and RTL — the scroll math still assumes LTR scrollLeft. RTL was broken before this PR too, but a scroll container makes it tractable in a way the transform track never was. Happy to take it in a follow-up.

Screenshot

Visually identical — the layout, peek, arrows and indicators are unchanged. The entire difference is in how it feels to swipe, which a screenshot cannot show. Worth a minute on a real phone rather than a still image.

Stakeholders

@cdrini @RayBB

Supersedes #13101, which spent ~170 lines approximating axis locking, fling velocity and snap selection — all of which the browser does natively here. Happy to close that one if this direction looks right.

Replaces the transform-based track and its hand-rolled gesture and spring
physics with a native scroll container plus CSS scroll snapping. Swipe
momentum, overscroll, axis locking, and trackpad and wheel scrolling now
come from the platform instead of being approximated in JS.

Snap points are declared on the items (`start` every Nth, `end` on the
last), so the browser owns landing — including the ragged final page,
which rests flush against the viewport edge.

Removed: pointer/touch handlers, axis lock, velocity smoothing, spring
animation, rubber-band, page offset table, nearest-page search, wheel
paging, drag-click suppression, and index-based lazy loading. Off-page
items are no longer inert; in a scroll container they are legitimately
reachable by tab, screen reader, find-in-page and voice control.

Still custom, per browser support: scrollend is Safari 26.2 only, so it
is feature-detected with a debounced scroll fallback; scrollbar-width is
paired with a ::-webkit-scrollbar fallback; item widths stay on
ResizeObserver rather than container queries, which would raise the floor
to Safari 16 and fail hard rather than degrade.

Public API is unchanged. Adds 37 unit tests; jest needed a transform
block since .babelrc does not apply to node_modules and lit ships ESM.
@lokesh
lokesh requested a review from cdrini July 28, 2026 17:07
Trim the explanatory blocks down to one or two lines each. The rationale
for choosing native scrolling belongs in the PR, not repeated inline.
@lokesh lokesh added On Testing Needs: Review This issue/PR needs to be reviewed in order to be closed or merged (see comments). [managed] labels Jul 28, 2026
Upstream moved the carousel design-docs page from design/carousel.html.jinja
to design/components/carousel.html.jinja as part of the design-system docs
restructure. Reapplied this branch's description rewrite (spring physics ->
native scroll snapping) at the new path.

Claude-Session: https://claude.ai/code/session_01VaHTJt7VK8TXfC219ibatv

@cdrini cdrini left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works like a charm! One ux note is that if i swipe very quickly, it moves past multiple pages. Note sure if we want to prevent that.

Comment thread openlibrary/components/lit/OlCarousel.js Outdated
Comment thread openlibrary/components/lit/OlCarousel.js Outdated
@cdrini cdrini added Ready to Merge and removed Needs: Review This issue/PR needs to be reviewed in order to be closed or merged (see comments). [managed] labels Aug 13, 2026
lokesh added 3 commits August 14, 2026 10:57
The tablist and per-indicator aria-labels were hardcoded English. Add
label-pages and label-go-to-page attributes with English defaults,
following the same pattern as <ol-pagination>. The template holds
{page}/{total} placeholders so translations can reorder them.

Claude-Session: https://claude.ai/code/session_016RMfy95Q9ritR7KF727ouR
The IntersectionObserver re-implemented Slick's data-lazy swap so the
component could render Slick-shaped cards. In a scroll container that is
the browser's job: a real src plus loading="lazy" defers off-page covers
on its own, because the viewport clips its overflow.

Nothing renders data-lazy cards into <ol-carousel> today, and the card
template gets rewritten when it does. Document the contract on the
component and the design page instead of carrying the shim.

Claude-Session: https://claude.ai/code/session_016RMfy95Q9ritR7KF727ouR
@lokesh
lokesh merged commit 220b3ef into internetarchive:master Aug 14, 2026
5 checks passed
@RayBB

RayBB commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Now that this is merged, are we going to migrate the existing carousels to it? Is there an issue for that

@lokesh

lokesh commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

@RayBB
Currently the UI modernization epic issue has a subtask for this:
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants