Skip to content

[IRN-6718] [BpkCardListCarousel] Add physical page-based scrolling to desktop#4138

Merged
kerrie-wu merged 31 commits intomainfrom
IRN-6178-add-physical-scrolling-to-desktop-row-to-rail
Feb 10, 2026
Merged

[IRN-6718] [BpkCardListCarousel] Add physical page-based scrolling to desktop#4138
kerrie-wu merged 31 commits intomainfrom
IRN-6178-add-physical-scrolling-to-desktop-row-to-rail

Conversation

@grc-ong
Copy link
Copy Markdown
Contributor

@grc-ong grc-ong commented Jan 23, 2026

Summary

This PR adds native physical scrolling behavior to the BpkCardListCarousel component on desktop and tablet devices, replacing the previous button-only navigation with a more natural scrolling experience while maintaining the page-based navigation model.

Changes

Key improvements:

  • Physical page-based scrolling: Users can now scroll through cards using mouse wheel, trackpad, or touch
    gestures on desktop/tablet, with scrolling snapping to pages rather than individual cards
  • Bidirectional synchronization: The carousel now properly syncs between programmatic scrolling
    (pagination buttons) and user-initiated scrolling (wheel/touch), preventing conflicts between the two
  • Improved scroll lock mechanism: Replaced the previous scroll lock system with a more robust
    usePageScrollSync hook that uses a silence-based lock release to distinguish between user and
    programmatic scrolling

Technical changes:

  • Refactored scroll synchronization logic into a new usePageScrollSync hook in utils.tsx
  • Changed carousel container from overflow-x: hidden to overflow-x: scroll on desktop/tablet
  • Added .bpk-card-list-row-rail__card--page-start class to enable page-based scroll snapping (only first
    card of each page snaps on desktop/tablet)
  • Removed scroll-snap-stop: always from all cards and applied it only to page-start cards on desktop/tablet
  • Mobile behavior unchanged - continues to use per-card scroll snapping
  • Removed the old useScrollToCard and lockScroll utility functions
  • Updated test snapshots and added comprehensive test coverage for the new scroll behavior

Testing

This has been manually tested on:

Desktop:

  • Chrome
  • Safari
  • Firefox

iPad:

  • Chrome
  • Safari

Android phone:

  • Chrome
  • Samsung

iPhone:

  • Chrome
  • Safari

Remember to include the following changes:

  • Ensure the PR title includes the name of the component you are changing so it's clear in the release notes for consumers of the changes in the version e.g [Clover-123][BpkButton] Updating the colour
  • README.md (If you have created a new component)
  • Component README.md
  • Tests
  • Accessibility tests
    • The following checks were performed:
      • Ability to navigate using a keyboard only
      • Zoom functionality (Deque University explanation):
        • The page SHOULD be functional AND readable when only the text is magnified to 200% of its initial size
        • Pages must reflow as zoom increases up to 400% so that content continues to be presented in only one column i.e. Content MUST NOT require scrolling in two directions (both vertically and horizontally)
      • Ability to navigate using a screen reader only
  • Storybook examples created/updated
  • For breaking changes or deprecating components/properties, migration guides added to the description of the PR. If the guide has large changes, consider creating a new Markdown page inside the component's docs folder and link it here

@skyscanner-backpack-bot
Copy link
Copy Markdown

skyscanner-backpack-bot Bot commented Jan 23, 2026

Warnings
⚠️

Package source files (e.g. packages/package-name/src/Component.js) were updated, but snapshots weren't. Have you checked that the tests still pass?

Browser support

If this is a visual change, make sure you've tested it in multiple browsers.

Generated by 🚫 dangerJS against 7926a96

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4138 to see this build running in a browser.

@grc-ong grc-ong added enhancement minor Non breaking change labels Jan 23, 2026
@grc-ong grc-ong changed the title [IRN-6718] WIP: add physical page-based scrolling to desktop [IRN-6718] [BpkCardListCarousel] Add physical page-based scrolling to desktop Jan 23, 2026
@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4138 to see this build running in a browser.

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4138 to see this build running in a browser.


display: flex;
overflow-x: hidden;
overflow-x: scroll;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

enables scroll

// Negative margin to ensure box-shadow of content is not cut off
margin-block: -(tokens.bpk-spacing-lg());
margin-inline: -(tokens.bpk-spacing-md());
-webkit-overflow-scrolling: touch;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

enables native scroll on safari


@include breakpoints.bpk-breakpoint-above-mobile {
// On tablet and desktop, only snap to page-start cards for page-based scrolling
scroll-snap-align: none;
Copy link
Copy Markdown
Contributor Author

@grc-ong grc-ong Jan 27, 2026

Choose a reason for hiding this comment

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

this means not specifying where on the card to snap to so no snap scroll
because logic to snap every so cards will be determined elsewhere

&__card--page-start {
@include breakpoints.bpk-breakpoint-above-mobile {
scroll-snap-align: start;
scroll-snap-stop: always;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

'always' makes it so it stops at the next card as opposed to 'normal', the same amount of force on the trackpad will scroll through more options


const stateScrollingLockRef = useRef(false);
const openSetStateLockTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [lockVersion, setLockVersion] = useState(0);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

state to share between page-based scrolling and the button navigation

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4138 to see this build running in a browser.

// Mobile: scroll to individual card (use currentIndex directly)
useScrollToCard(
currentIndex * initiallyShownCards,
isMobile ? currentIndex : currentIndex * initiallyShownCards,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

separating mobile experience that snaps to every card

root,
cardRefs,
stateScrollingLockRef,
openSetStateLockTimeoutRef,
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

not sure what this is yet

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4138 to see this build running in a browser.

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4138 to see this build running in a browser.

useEffect(() => {
const firstVisible = visibilityList.indexOf(1);
if (firstVisible >= 0) {
if (firstVisible >= 0 && stateScrollingLockRef.current) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Only set the index from within our component if we're locked, meaning there has been a user scrolling interaction


const commonProps = {
className: getClassName(`bpk-card-list-row-rail__${layout}__card`),
className: `${getClassName(`bpk-card-list-row-rail__${layout}__card`)}${isPageStart ? ` ${getClassName('bpk-card-list-row-rail__card--page-start')}` : ''}`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Add the page start classes to the correct cards

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4138 to see this build running in a browser.

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4138 to see this build running in a browser.

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4138 to see this build running in a browser.

@chiefkes Max Liefkes (chiefkes) marked this pull request as ready for review February 6, 2026 10:41
Comment on lines +86 to +94
usePageScrollSync({
currentIndex,
setCurrentIndex,
initiallyShownCards,
cardRefs,
stateScrollingLockRef,
);
visibilityList,
container: root,
enabled: !isMobile,
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

refactor various scroll related logic into a consolidated hook to make it easier to reason about and control the various scroll mechanisms

Comment on lines -164 to -182
useEffect(() => {
const container = root;
if (isMobile || !container) return undefined;

const lockScrollDuringInteraction = () => {
lockScroll(stateScrollingLockRef, openSetStateLockTimeoutRef);
};

container.addEventListener('wheel', lockScrollDuringInteraction);
container.addEventListener('touchmove', lockScrollDuringInteraction);

return () => {
container.removeEventListener('touchmove', lockScrollDuringInteraction);
container.removeEventListener('wheel', lockScrollDuringInteraction);
if (openSetStateLockTimeoutRef.current) {
clearTimeout(openSetStateLockTimeoutRef.current);
}
};
}, [root]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This responsibility moved to new usePageScrollSync hook

Comment on lines -198 to -206
useEffect(() => {
const firstVisible = visibilityList.indexOf(1);
if (firstVisible >= 0) {
const newIndex = Math.floor(firstVisible / initiallyShownCards);
if (newIndex !== currentIndex) {
setCurrentIndex(newIndex);
}
}
}, [initiallyShownCards]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This responsibility moved to new usePageScrollSync hook

});
};

export const useScrollToCard = (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This responsibility moved to new usePageScrollSync hook

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4138 to see this build running in a browser.

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4138 to see this build running in a browser.

className: getClassName(`bpk-card-list-row-rail__${layout}__card`),
className: getClassName(
`bpk-card-list-row-rail__${layout}__card`,
isPageStart && 'bpk-card-list-row-rail__card--page-start',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Page start to allow the scroll snapping styles 🚀

container.getBoundingClientRect().bottom > 0 &&
container.getBoundingClientRect().bottom <= window.innerHeight;

if (!isVisible) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

early return if not visible to not scroll

Comment on lines +154 to +157
const targetCard = cardRefs.current?.[currentIndex * initiallyShownCards];
if (!targetCard) {
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

early return if the target doesn't exist, sensible

Copy link
Copy Markdown
Contributor

@jimmycook Jimmy cook (jimmycook) left a comment

Choose a reason for hiding this comment

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

looks good from Irn Bru side


export const RELEASE_LOCK_DELAY = 20;
export const RENDER_BUFFER_SIZE = 3; No newline at end of file
export const RELEASE_LOCK_DELAY = 200;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is 200ms derived from empirical testing across trackpad/wheel/iPad touch? Any risk of sluggish page indicator updates?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hi - yes 200 was a sweetspot value that I arrived at after testing on multiple devices and browsers. At this value there is no flickering of the page index indicators (even on lower powered devices), and the page indicator updates feel natural and not at all sluggish

cardDimensionStyle.height = `${firstCardHeightRef.current}px`;
}

const isPageStart = index % initiallyShownCards === 0;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor thought: since page-start is calculated via index % initiallyShownCards, do we handle responsive changes and the “incomplete last page” case as expected? Might be worth a quick test just to make sure snapping always lands on a valid page start.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good point! I've had a look at a storybook example with 13 cards. It appears to work as expected and handles responsive changes well too. When we move from index 9 to 12, card visibility goes from [9, 10, 11], to [10, 11, 12] which is intuitive behaviour. With page index still at 12 and responsively moving to mobile, visibility is snapped to [12] as expected

bpk-cardlist-responsive.webm

@skyscanner-backpack-bot
Copy link
Copy Markdown

Visit https://backpack.github.io/storybook-prs/4138 to see this build running in a browser.

@kerrie-wu kerrie-wu merged commit 61cb207 into main Feb 10, 2026
15 checks passed
@kerrie-wu kerrie-wu deleted the IRN-6178-add-physical-scrolling-to-desktop-row-to-rail branch February 10, 2026 10:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants