diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md
index 9cdb7f9378..bfe46efeb3 100644
--- a/packages/common/CHANGELOG.md
+++ b/packages/common/CHANGELOG.md
@@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.
+## 9.23.0 ((8/27/2026, 05:05 PM PST))
+
+This is an artificial version bump with no new change.
+
## 9.22.1 ((8/25/2026, 02:10 PM PST))
This is an artificial version bump with no new change.
diff --git a/packages/common/package.json b/packages/common/package.json
index cda58e4228..17536768a5 100644
--- a/packages/common/package.json
+++ b/packages/common/package.json
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-common",
- "version": "9.22.1",
+ "version": "9.23.0",
"description": "Coinbase Design System - Common",
"repository": {
"type": "git",
diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md
index 881ab1fb39..81907a3b55 100644
--- a/packages/mcp-server/CHANGELOG.md
+++ b/packages/mcp-server/CHANGELOG.md
@@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file.
+## 9.23.0 ((8/27/2026, 05:05 PM PST))
+
+This is an artificial version bump with no new change.
+
## 9.22.1 ((8/25/2026, 02:10 PM PST))
This is an artificial version bump with no new change.
diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json
index 1f242f008b..553f38ce95 100644
--- a/packages/mcp-server/package.json
+++ b/packages/mcp-server/package.json
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mcp-server",
- "version": "9.22.1",
+ "version": "9.23.0",
"description": "Coinbase Design System - MCP Server",
"repository": {
"type": "git",
diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md
index 208220f348..5289095593 100644
--- a/packages/mobile/CHANGELOG.md
+++ b/packages/mobile/CHANGELOG.md
@@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.
+## 9.23.0 (8/27/2026 PST)
+
+#### 🚀 Updates
+
+- Add uncontrolled initialPage prop to Carousel. [[#864](https://github.com/coinbase/cds/pull/864)]
+
## 9.22.1 (8/25/2026 PST)
#### 🐞 Fixes
diff --git a/packages/mobile/package.json b/packages/mobile/package.json
index 914715cbe9..87cf42ab33 100644
--- a/packages/mobile/package.json
+++ b/packages/mobile/package.json
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mobile",
- "version": "9.22.1",
+ "version": "9.23.0",
"description": "Coinbase Design System - Mobile",
"repository": {
"type": "git",
diff --git a/packages/mobile/src/carousel/Carousel.tsx b/packages/mobile/src/carousel/Carousel.tsx
index a86c0cb217..70890470b4 100644
--- a/packages/mobile/src/carousel/Carousel.tsx
+++ b/packages/mobile/src/carousel/Carousel.tsx
@@ -3,6 +3,7 @@ import React, {
useCallback,
useEffect,
useImperativeHandle,
+ useLayoutEffect,
useMemo,
useRef,
useState,
@@ -188,6 +189,12 @@ export type CarouselBaseProps = SharedProps &
* @default 'page'
*/
snapMode?: 'item' | 'page';
+ /**
+ * Zero-based page index to show on first layout, with no animation. Uncontrolled —
+ * updating it later does not move the carousel. Out-of-range values are clamped.
+ * @default 0
+ */
+ initialPage?: number;
/**
* Hides the navigation arrows (previous/next buttons).
*/
@@ -563,6 +570,7 @@ export const Carousel = memo(
paginationVariant,
drag = 'snap',
snapMode = 'page',
+ initialPage,
NavigationComponent = DefaultCarouselNavigation,
PaginationComponent = DefaultCarouselPagination,
style,
@@ -581,13 +589,14 @@ export const Carousel = memo(
...props
} = mergedProps;
const carouselScrollX = useRef(0);
+ const hasAppliedInitialPageRef = useRef(false);
const animationApi = useSpring({
x: carouselScrollX.current,
config: animationConfig,
});
- const [activePageIndex, setActivePageIndex] = useState(0);
+ const [activePageIndex, setActivePageIndex] = useState(() => Math.max(0, initialPage ?? 0));
const [containerSize, onLayout] = useLayout();
const [carouselItemRects, setCarouselItemRects] = useState<{
[itemId: string]: Rect;
@@ -766,7 +775,7 @@ export const Carousel = memo(
});
const goToPage = useCallback(
- (page: number) => {
+ (page: number, { animate: shouldAnimate = true }: { animate?: boolean } = {}) => {
const newPage = Math.max(0, Math.min(totalPages - 1, page));
updateActivePageIndex(newPage);
updateVisibleCarouselItems(pageOffsets[newPage]);
@@ -777,7 +786,11 @@ export const Carousel = memo(
: pageOffsets[newPage];
carouselScrollX.current = targetOffset;
- animationApi.x.start({ to: targetOffset, config: animationConfig });
+ if (shouldAnimate) {
+ animationApi.x.start({ to: targetOffset, config: animationConfig });
+ } else {
+ animationApi.x.set(targetOffset);
+ }
reset();
},
[
@@ -792,6 +805,16 @@ export const Carousel = memo(
],
);
+ useLayoutEffect(() => {
+ if (hasAppliedInitialPageRef.current || totalPages === 0) return;
+ hasAppliedInitialPageRef.current = true;
+
+ const targetPage = initialPage ?? 0;
+ if (targetPage > 0) {
+ goToPage(targetPage, { animate: false });
+ }
+ }, [totalPages, initialPage, goToPage]);
+
useImperativeHandle(
ref,
() => ({
diff --git a/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx b/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx
index 2ed4cb3a50..400aaf0e01 100644
--- a/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx
+++ b/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx
@@ -605,6 +605,30 @@ const ImperativeApiExample = () => {
);
};
+const InitialPageExample = () => {
+ const theme = useTheme();
+
+ return (
+
+
+ {sampleItems.slice(0, 5).map((item, index) => (
+
+ {item}
+
+ ))}
+
+
+ );
+};
+
const LoopingExamples = () => {
const theme = useTheme();
@@ -833,6 +857,7 @@ export default function CarouselScreen() {
+
);
}
diff --git a/packages/mobile/src/carousel/__tests__/Carousel.test.tsx b/packages/mobile/src/carousel/__tests__/Carousel.test.tsx
index d304996017..1176dfe944 100644
--- a/packages/mobile/src/carousel/__tests__/Carousel.test.tsx
+++ b/packages/mobile/src/carousel/__tests__/Carousel.test.tsx
@@ -5,7 +5,12 @@ import { Box } from '../../layout/Box';
import { VStack } from '../../layout/VStack';
import { Text } from '../../typography/Text';
import { DefaultThemeProvider } from '../../utils/testHelpers';
-import { Carousel, type CarouselImperativeHandle, useCarouselContext } from '../Carousel';
+import {
+ Carousel,
+ type CarouselImperativeHandle,
+ type CarouselPaginationComponentProps,
+ useCarouselContext,
+} from '../Carousel';
import { CarouselItem } from '../CarouselItem';
// Mock react-native-gesture-handler with gesture simulation capabilities
@@ -1529,4 +1534,154 @@ describe('Carousel', () => {
expect(onChangePage).toHaveBeenCalledWith(0);
});
});
+
+ describe('Initial Page', () => {
+ // 8 items at width 200 in a 400px container, snapMode="item" => 7 pages (indices 0-6).
+ const itemCount = 8;
+
+ // Custom pagination that surfaces the active page index for assertions.
+ const ActivePagePagination = ({
+ activePageIndex,
+ totalPages,
+ }: CarouselPaginationComponentProps) => (
+ {`${activePageIndex}/${totalPages}`}
+ );
+
+ beforeEach(() => {
+ mockGestureHandlers.onStart = undefined;
+ mockGestureHandlers.onUpdate = undefined;
+ mockGestureHandlers.onEnd = undefined;
+ jest.clearAllMocks();
+ });
+
+ it('opens on the provided initialPage without firing onChangePage on mount', async () => {
+ const onChangePage = jest.fn();
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('active-page-index')).toHaveTextContent('3/7');
+ });
+
+ expect(onChangePage).not.toHaveBeenCalled();
+ });
+
+ it('defaults to the first page when initialPage is omitted', async () => {
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('active-page-index')).toHaveTextContent('0/7');
+ });
+ });
+
+ it.each`
+ initialPage | expectedIndex | description
+ ${99} | ${6} | ${'clamps an initialPage above the last page to the last page'}
+ ${-5} | ${0} | ${'clamps a negative initialPage to the first page'}
+ `('$description', async ({ initialPage, expectedIndex }) => {
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('active-page-index')).toHaveTextContent(`${expectedIndex}/7`);
+ });
+ });
+
+ it('lets a later imperative goToPage override the initial page', async () => {
+ const onChangePage = jest.fn();
+
+ const TestComponent = () => {
+ const carouselRef = useRef(null);
+
+ return (
+
+
+ carouselRef.current?.goToPage(1)} testID="go-to-page-1">
+ Go to Page 1
+
+
+ {Array.from({ length: itemCount }, (_, index) => {
+ const itemId = `item-${index}`;
+ return (
+
+
+ Item {index + 1}
+
+
+ );
+ })}
+
+
+
+ );
+ };
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('active-page-index')).toHaveTextContent('3/7');
+ });
+
+ fireEvent.press(screen.getByTestId('go-to-page-1'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('active-page-index')).toHaveTextContent('1/7');
+ });
+ expect(onChangePage).toHaveBeenCalledWith(1);
+ });
+
+ it('lets a later drag override the initial page', async () => {
+ const onChangePage = jest.fn();
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('active-page-index')).toHaveTextContent('3/7');
+ });
+
+ onChangePage.mockClear();
+
+ // Drag toward earlier pages from the seeded page 3.
+ simulateDragGesture(200, 0);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('active-page-index')).toHaveTextContent('2/7');
+ });
+ expect(onChangePage).toHaveBeenCalledWith(2);
+ });
+ });
});
diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md
index dff9085370..b713bc8ddb 100644
--- a/packages/web/CHANGELOG.md
+++ b/packages/web/CHANGELOG.md
@@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.
+## 9.23.0 (8/27/2026 PST)
+
+#### 🚀 Updates
+
+- Add uncontrolled initialPage prop to Carousel. [[#864](https://github.com/coinbase/cds/pull/864)]
+
## 9.22.1 (8/25/2026 PST)
#### 🐞 Fixes
diff --git a/packages/web/package.json b/packages/web/package.json
index ee5f206be8..48ee7ca7aa 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-web",
- "version": "9.22.1",
+ "version": "9.23.0",
"description": "Coinbase Design System - Web",
"repository": {
"type": "git",
diff --git a/packages/web/src/carousel/Carousel.tsx b/packages/web/src/carousel/Carousel.tsx
index 6105bd3ffc..914e66a42b 100644
--- a/packages/web/src/carousel/Carousel.tsx
+++ b/packages/web/src/carousel/Carousel.tsx
@@ -29,6 +29,7 @@ import {
import { cx } from '../cx';
import { useComponentConfig } from '../hooks/useComponentConfig';
+import { useIsoEffect } from '../hooks/useIsoEffect';
import { type BoxBaseProps, type BoxDefaultElement, type BoxProps } from '../layout/Box';
import { HStack } from '../layout/HStack';
import { VStack } from '../layout/VStack';
@@ -226,6 +227,12 @@ export type CarouselBaseProps = SharedProps &
* @default 'page'
*/
snapMode?: 'item' | 'page';
+ /**
+ * Zero-based page index to show on first layout, with no animation. Uncontrolled —
+ * updating it later does not move the carousel. Out-of-range values are clamped.
+ * @default 0
+ */
+ initialPage?: number;
/**
* Hides the navigation arrows (previous/next buttons and autoplay control).
*
@@ -719,6 +726,7 @@ export const Carousel = memo(
paginationVariant,
drag = 'snap',
snapMode = 'page',
+ initialPage,
NavigationComponent = DefaultCarouselNavigation,
PaginationComponent = DefaultCarouselPagination,
className,
@@ -742,8 +750,9 @@ export const Carousel = memo(
const animationApi = useAnimation();
const carouselScrollX = useMotionValue(0);
const dragControls = useDragControls();
+ const hasAppliedInitialPageRef = useRef(false);
- const [activePageIndex, setActivePageIndex] = useState(0);
+ const [activePageIndex, setActivePageIndex] = useState(() => Math.max(0, initialPage ?? 0));
const containerRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
const carouselItemRefMap = useRefMap();
@@ -1041,7 +1050,7 @@ export const Carousel = memo(
});
const goToPage = useCallback(
- (page: number) => {
+ (page: number, { animate: shouldAnimate = true }: { animate?: boolean } = {}) => {
const newPage = Math.max(0, Math.min(totalPages - 1, page));
updateActivePageIndex(newPage);
updateVisibleCarouselItems(pageOffsets[newPage]);
@@ -1051,7 +1060,11 @@ export const Carousel = memo(
.offset
: pageOffsets[newPage];
- animate(carouselScrollX, -targetOffset, animationConfig);
+ if (shouldAnimate) {
+ animate(carouselScrollX, -targetOffset, animationConfig);
+ } else {
+ carouselScrollX.set(-targetOffset);
+ }
reset();
},
[
@@ -1066,6 +1079,16 @@ export const Carousel = memo(
],
);
+ useIsoEffect(() => {
+ if (hasAppliedInitialPageRef.current || totalPages === 0) return;
+ hasAppliedInitialPageRef.current = true;
+
+ const targetPage = initialPage ?? 0;
+ if (targetPage > 0) {
+ goToPage(targetPage, { animate: false });
+ }
+ }, [totalPages, initialPage, goToPage]);
+
useImperativeHandle(
ref,
() => ({
diff --git a/packages/web/src/carousel/__stories__/Carousel.stories.tsx b/packages/web/src/carousel/__stories__/Carousel.stories.tsx
index eb565af765..7e4c60606f 100644
--- a/packages/web/src/carousel/__stories__/Carousel.stories.tsx
+++ b/packages/web/src/carousel/__stories__/Carousel.stories.tsx
@@ -658,6 +658,21 @@ const LoopingExamples = () => (
);
+const InitialPageExample = () => (
+
+ {sampleItems.slice(0, 5).map((item, index) => (
+
+ {item}
+
+ ))}
+
+);
+
export const All = () => (
@@ -666,5 +681,6 @@ export const All = () => (
+
);
diff --git a/packages/web/src/carousel/__tests__/Carousel.test.tsx b/packages/web/src/carousel/__tests__/Carousel.test.tsx
index 64cef9ad01..7e8d75b02d 100644
--- a/packages/web/src/carousel/__tests__/Carousel.test.tsx
+++ b/packages/web/src/carousel/__tests__/Carousel.test.tsx
@@ -7,7 +7,7 @@ import { Box } from '../../layout/Box';
import { VStack } from '../../layout/VStack';
import { Text } from '../../typography/Text';
import { DefaultThemeProvider } from '../../utils/test';
-import type { CarouselImperativeHandle } from '../Carousel';
+import type { CarouselImperativeHandle, CarouselPaginationComponentProps } from '../Carousel';
import { Carousel } from '../Carousel';
import { CarouselItem } from '../CarouselItem';
@@ -1656,4 +1656,117 @@ describe('Carousel', () => {
});
});
});
+
+ describe('Initial Page', () => {
+ // 8 items at width 200 in an 800px container, snapMode="item" => 5 pages (indices 0-4).
+ const itemCount = 8;
+
+ // Custom pagination that surfaces the active page index for assertions.
+ const ActivePagePagination = ({
+ activePageIndex,
+ totalPages,
+ }: CarouselPaginationComponentProps) => (
+ {`${activePageIndex}/${totalPages}`}
+ );
+
+ it('opens on the provided initialPage without firing onChangePage on mount', async () => {
+ const onChangePage = jest.fn();
+
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('active-page-index')).toHaveTextContent('3/5');
+ });
+
+ expect(onChangePage).not.toHaveBeenCalled();
+ });
+
+ it('defaults to the first page when initialPage is omitted', async () => {
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('active-page-index')).toHaveTextContent('0/5');
+ });
+ });
+
+ it.each`
+ initialPage | expectedIndex | description
+ ${99} | ${4} | ${'clamps an initialPage above the last page to the last page'}
+ ${-5} | ${0} | ${'clamps a negative initialPage to the first page'}
+ `('$description', async ({ initialPage, expectedIndex }) => {
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('active-page-index')).toHaveTextContent(`${expectedIndex}/5`);
+ });
+ });
+
+ it('lets a later imperative goToPage override the initial page', async () => {
+ const onChangePage = jest.fn();
+
+ const TestComponent = () => {
+ const carouselRef = useRef(null);
+
+ return (
+
+
+
+
+ {Array.from({ length: itemCount }, (_, index) => (
+
+
+ Item {index + 1}
+
+
+ ))}
+
+
+
+ );
+ };
+
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('active-page-index')).toHaveTextContent('3/5');
+ });
+
+ await user.click(screen.getByTestId('go-to-page-1'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('active-page-index')).toHaveTextContent('1/5');
+ });
+ expect(onChangePage).toHaveBeenCalledWith(1);
+ });
+ });
});