Skip to content

feat(mobile): add initialPage prop to Carousel - #864

Open
caitlin-coyiuto-cb wants to merge 1 commit into
coinbase:masterfrom
caitlin-coyiuto-cb:ccoyiuto/CB1-2048
Open

feat(mobile): add initialPage prop to Carousel#864
caitlin-coyiuto-cb wants to merge 1 commit into
coinbase:masterfrom
caitlin-coyiuto-cb:ccoyiuto/CB1-2048

Conversation

@caitlin-coyiuto-cb

@caitlin-coyiuto-cb caitlin-coyiuto-cb commented Aug 28, 2026

Copy link
Copy Markdown

CB1-2048

What changed? Why?

  • Add an uncontrolled initialPage prop to the cds-mobile Carousel so consumers can open it on a non-zero page with no animation.
  • Seed activePageIndex at init + a one-time, didInitRef-guarded non-animated seek on first measurement (instant .set(), no slide from page 0); reads initialPage via a ref since data can arrive async.
  • No onChangePage on mount; clamps out-of-range; routes through findNearestLoopOffset when looping; a later goToPage/drag still wins.
  • Replaces the consumer-side requestAnimationFrame + goToPage workaround (clears TODO(CB1-2026)); unblocks the RN adoption PR (PR 2).

Root cause (required for bugfixes)

N/A — feature, not a bugfix.

UI changes

Adds an "Initial Page (opens on page 3)" example to the mobile Carousel stories. Web unaffected.

CB1-2048-cds.mov

Testing

How has it been tested?

  • Unit tests
  • Interaction tests
  • Pseudo State tests
  • Manual - Web
  • Manual - Android (Emulator / Device)
  • Manual - iOS (Emulator / Device)

Testing instructions

Automated: New Initial Page tests in Carousel.test.tsx — opens on initialPage; onChangePage not called on mount; out-of-range clamps (99→6, -5→0); later goToPage and drag override the initial page. Full file 97/97 pass; mobile typecheck + lint clean.

Manual: yarn nx run expo-app:ios → open the Carousel route → the "Initial Page (opens on page 3)" example lands on page 3 with no slide from page 1; dragging/pagination still work.

Illustrations/Icons Checklist

N/A — no changes under packages/illustrations/** or packages/icons/**.

Change management

type=routine
risk=low
impact=sev5

automerge=false

@cb-heimdall

Copy link
Copy Markdown
Collaborator

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/2
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 1
Global minimum 0
Max 1
1
1 if commit is unverified 1
Sum 2
CODEOWNERS 🟡 See below

🟡 CODEOWNERS

Code Owner Status Calculation
ui-systems-eng-team 🟡 0/1
Denominator calculation
Additional CODEOWNERS Requirement
Show calculation
Sum 0
0
From CODEOWNERS 1
Sum 1

@caitlin-coyiuto-cb caitlin-coyiuto-cb changed the title feat(mobile): add initialPage prop to Carousel feat(carousel): add initialPage prop Aug 28, 2026
@caitlin-coyiuto-cb caitlin-coyiuto-cb changed the title feat(carousel): add initialPage prop feat(mobile): add initialPage prop to Carousel Aug 28, 2026
@caitlin-coyiuto-cb
caitlin-coyiuto-cb marked this pull request as ready for review August 28, 2026 16:22

@hcopp hcopp left a comment

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.

Thanks for the PR! Had some feedback

Comment thread packages/web/CHANGELOG.md
## 9.23.0 ((8/27/2026, 05:05 PM PST))

This is an artificial version bump with no new change.

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.

@caitlin-coyiuto-cb are we able to get web parity here? 🙏

* page with no animation, and `onChangePage` does not fire on mount. Out-of-range values
* are clamped to `[0, totalPages - 1]`.
* @default 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.

nit: can we shorten this so it is readable on our docs? Maybe something like

/**
 * Zero-based page index to show on first layout. Out-of-range values are clamped.
 * @default 0
 */
initialPageIndex?: number;

loopLength,
animationApi.x,
updateVisibleCarouselItems,
]);

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.

What if we had a single useLayoutEffect instead, maybe something like

    useLayoutEffect(() => {
      if (didInitRef.current || totalPages === 0) return;

      didInitRef.current = true;
      applyPage(initialPageIndex); // here we could modify applyPage to have some sort of toggle for notifying and animation
    }, [totalPages, initialPageIndex, applyPage]);

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.

+1 the useLayoutEffect sounds like the correct React primitive to use since our side-effect deals with visual measurements

// Kept in sync each render so the one-time seek effect reads the value current at first
// measurement (the target page may arrive asynchronously after mount).
const initialPageRef = useRef(initialPage);
initialPageRef.current = initialPage;

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.

How big of a concern is it if the prop is changed between initial load and when values are set? I think it should be fine without a ref here.

// Kept in sync each render so the one-time seek effect reads the value current at first
// measurement (the target page may arrive asynchronously after mount).
const initialPageRef = useRef(initialPage);
initialPageRef.current = initialPage;

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 will update the ref when the initial page changes which shouldn't matter unless the component remounts and in that instance the useRef will evaluate again

});

const [activePageIndex, setActivePageIndex] = useState(0);
const [activePageIndex, setActivePageIndex] = useState(() => Math.max(0, initialPage ?? 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.

we should probably use the value from the ref here in case the initialPage prop does in fact change. If that were the case then the activePage state would also change

if (didInitRef.current || !hasCalculatedDimensions || totalPages === 0) return;
didInitRef.current = true;

const target = Math.max(0, Math.min(totalPages - 1, initialPageRef.current ?? 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.

const target = Math.max(0, Math.min(totalPages - 1, initialPageRef.current ?? 0));
      if (target === 0) return;

      const baseOffset = pageOffsets[target];
      const seekOffset = isLoopingActive
        ? findNearestLoopOffset(carouselScrollX.current, [baseOffset], loopLength).offset
        : baseOffset;

      carouselScrollX.current = seekOffset;
      animationApi.x.set(seekOffset);
      updateVisibleCarouselItems(seekOffset);

This logic looks very similar to what we do on normal page navigation. Is there a way to cleanly share the logic?

Add an uncontrolled `initialPage` prop to the cds-mobile and cds-web Carousel so
a consumer can open it on a non-zero page with no animation. Seeds
`activePageIndex` at init (no `onChangePage` on mount) and performs a one-time,
`didInitRef`-guarded non-animated seek on the first successful measurement —
reading `initialPage` via a ref and routing through `findNearestLoopOffset` when
looping. Adds `initialPage` tests and a story example on both platforms.

CB1-2048

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

4 participants