diff --git a/docs/component-docs.config.ts b/docs/component-docs.config.ts
index 4196e8e7bd..e744d59b80 100644
--- a/docs/component-docs.config.ts
+++ b/docs/component-docs.config.ts
@@ -60,6 +60,17 @@ const pages = {
CardCover: 'Card/CardCover',
CardTitle: 'Card/CardTitle',
},
+ Carousel: {
+ Carousel: 'Carousel/Carousel',
+ CarouselItem: {
+ source: 'Carousel/CarouselItem',
+ component: 'CarouselItem',
+ },
+ CarouselItemContent: {
+ source: 'Carousel/CarouselItem',
+ component: 'CarouselItemContent',
+ },
+ },
Checkbox: {
Checkbox: 'Checkbox/Checkbox',
CheckboxItem: 'Checkbox/CheckboxItem',
diff --git a/docs/src/data/themeColors.ts b/docs/src/data/themeColors.ts
index 118114da27..0b4bcdd68e 100644
--- a/docs/src/data/themeColors.ts
+++ b/docs/src/data/themeColors.ts
@@ -101,6 +101,16 @@ export const themeColors = {
borderColor: 'theme.colors.outline',
},
},
+ Carousel: {
+ '-': {
+ backgroundColor: 'theme.colors.surfaceContainerHigh',
+ focusIndicatorColor: 'theme.colors.secondary',
+ stateLayerColor: 'theme.colors.onSurface',
+ },
+ outlined: {
+ borderColor: 'theme.colors.outlineVariant',
+ },
+ },
Dialog: {
'-': {
backgroundColor: 'theme.colors.elevation.level3',
diff --git a/example/node_modules b/example/node_modules
new file mode 120000
index 0000000000..7a0956aab3
--- /dev/null
+++ b/example/node_modules
@@ -0,0 +1 @@
+/Users/andrii.doroshenko/react-native-paper/example/node_modules
\ No newline at end of file
diff --git a/example/src/ExampleList.tsx b/example/src/ExampleList.tsx
index 8a2646d798..b79c848928 100644
--- a/example/src/ExampleList.tsx
+++ b/example/src/ExampleList.tsx
@@ -13,6 +13,7 @@ import BottomNavigationBarExample from './Examples/BottomNavigationBarExample';
import BottomNavigationExample from './Examples/BottomNavigationExample';
import ButtonExample from './Examples/ButtonExample';
import CardExample from './Examples/CardExample';
+import CarouselExample from './Examples/CarouselExample';
import CheckboxExample from './Examples/CheckboxExample';
import CheckboxItemExample from './Examples/CheckboxItemExample';
import ChipExample from './Examples/ChipExample';
@@ -58,6 +59,7 @@ export const mainExamples = {
BottomNavigation: BottomNavigationExample,
Button: ButtonExample,
Card: CardExample,
+ Carousel: CarouselExample,
Checkbox: CheckboxExample,
CheckboxItem: CheckboxItemExample,
Chip: ChipExample,
diff --git a/example/src/Examples/CarouselExample.tsx b/example/src/Examples/CarouselExample.tsx
new file mode 100644
index 0000000000..ada42fc122
--- /dev/null
+++ b/example/src/Examples/CarouselExample.tsx
@@ -0,0 +1,267 @@
+import * as React from 'react';
+import { Image, StyleSheet, View } from 'react-native';
+
+import {
+ Button,
+ Carousel,
+ CarouselItem,
+ CarouselItemContent,
+ Switch,
+ Text,
+ useTheme,
+ type CarouselHandle,
+ type CarouselLayout,
+} from 'react-native-paper';
+
+import ScreenWrapper from '../ScreenWrapper';
+
+const photos = [
+ {
+ id: 'beach',
+ title: 'Beach',
+ source: require('../../assets/images/beach.jpg'),
+ },
+ {
+ id: 'bridge',
+ title: 'Bridge',
+ source: require('../../assets/images/bridge.jpg'),
+ },
+ {
+ id: 'city',
+ title: 'City',
+ source: require('../../assets/images/city.jpg'),
+ },
+ {
+ id: 'forest',
+ title: 'Forest',
+ source: require('../../assets/images/forest.jpg'),
+ },
+ {
+ id: 'chameleon',
+ title: 'Chameleon',
+ source: require('../../assets/images/chameleon.jpg'),
+ },
+ {
+ id: 'strawberries',
+ title: 'Strawberries',
+ source: require('../../assets/images/strawberries.jpg'),
+ },
+];
+
+const Section = ({
+ title,
+ caption,
+ children,
+}: {
+ title: string;
+ caption?: string;
+ children: React.ReactNode;
+}) => (
+
+ {title}
+ {caption ? (
+
+ {caption}
+
+ ) : null}
+ {children}
+
+);
+
+type PhotoCarouselProps = {
+ layout: CarouselLayout;
+ alignment?: 'start' | 'center';
+ itemWidth?: number;
+ height?: number;
+ outlined?: boolean;
+ disabled?: boolean;
+ onIndexChange?: (index: number) => void;
+ ref?: React.RefObject;
+};
+
+const PhotoCarousel = ({
+ layout,
+ alignment,
+ itemWidth = 220,
+ height = 200,
+ outlined,
+ disabled,
+ onIndexChange,
+ ref,
+}: PhotoCarouselProps) => {
+ const theme = useTheme();
+
+ return (
+ photo.id}
+ aria-label="Photos"
+ renderItem={({ item, mask }) => (
+
+
+ {/* Pinned to the mask's leading edge, so it slides with the visible
+ rectangle and fades out as the item collapses. */}
+
+
+ {item.title}
+
+
+
+ )}
+ />
+ );
+};
+
+const CarouselExample = () => {
+ const carouselRef = React.useRef(null);
+ const [focused, setFocused] = React.useState(0);
+ const [outlined, setOutlined] = React.useState(false);
+ const [disabled, setDisabled] = React.useState(false);
+
+ return (
+
+
+ Items are never resized. What changes as an item moves through the
+ keylines is its mask rectangle and a translation — every item stays
+ measured at the large size throughout, which is why the photos never
+ reflow.
+
+
+
+
+ Focused item: {photos[focused]?.title}
+
+ {/* Programmatic moves settle on the same spring a fling does. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Outlined
+
+
+
+ Disabled
+
+
+
+ );
+};
+
+CarouselExample.title = 'Carousel';
+
+const styles = StyleSheet.create({
+ container: {
+ paddingVertical: 16,
+ gap: 8,
+ },
+ section: {
+ gap: 4,
+ paddingTop: 16,
+ },
+ caption: {
+ opacity: 0.7,
+ paddingHorizontal: 16,
+ },
+ carousel: {
+ paddingHorizontal: 16,
+ },
+ image: {
+ width: '100%',
+ height: '100%',
+ },
+ label: {
+ padding: 16,
+ },
+ labelPill: {
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ },
+});
+
+export default CarouselExample;
diff --git a/node_modules b/node_modules
new file mode 120000
index 0000000000..7552565393
--- /dev/null
+++ b/node_modules
@@ -0,0 +1 @@
+/Users/andrii.doroshenko/react-native-paper/node_modules
\ No newline at end of file
diff --git a/src/components/Carousel/Carousel.tsx b/src/components/Carousel/Carousel.tsx
new file mode 100644
index 0000000000..ae5038e3eb
--- /dev/null
+++ b/src/components/Carousel/Carousel.tsx
@@ -0,0 +1,440 @@
+import * as React from 'react';
+import {
+ StyleSheet,
+ type LayoutChangeEvent,
+ type NativeScrollEvent,
+ type NativeSyntheticEvent,
+ type StyleProp,
+ type ViewStyle,
+} from 'react-native';
+
+import Animated, {
+ cancelAnimation,
+ ReduceMotion,
+ runOnJS,
+ runOnUI,
+ scrollTo,
+ useAnimatedReaction,
+ useAnimatedRef,
+ useAnimatedScrollHandler,
+ useSharedValue,
+ withSpring,
+} from 'react-native-reanimated';
+
+import CarouselItemShell from './CarouselItemShell';
+import { createStrategy } from './strategy';
+import type {
+ CarouselAlignment,
+ CarouselLayout,
+ CarouselRenderItemInfo,
+ CarouselSnap,
+} from './types';
+import {
+ getDefaultCarouselColors,
+ getSnapScrollProps,
+ resolveSettleOffset,
+ visibleSlotCount,
+} from './utils';
+import { useInternalTheme } from '../../core/theming';
+import { useReduceMotion } from '../../theme/accessibility/ReduceMotionContext';
+import { toRawSpring } from '../../theme/tokens/sys/motion';
+import type { ThemeProp } from '../../theme/types';
+
+/** Items kept mounted either side of the on-screen ones. */
+const OVERSCAN = 2;
+
+export type CarouselHandle = {
+ /** Scrolls until `index` sits on the first focal keyline. */
+ scrollToIndex: (index: number, animated?: boolean) => void;
+};
+
+export type Props = {
+ /**
+ * Items to render.
+ */
+ data: readonly ItemT[];
+ /**
+ * Renders one item. Receives the item's live mask, which the content is
+ * expected to react to — see `CarouselItem` for the default behaviour.
+ */
+ renderItem: (info: CarouselRenderItemInfo) => React.ReactNode;
+ /**
+ * Height of the carousel. Every item is laid out at this height.
+ */
+ height: number;
+ /**
+ * Arrangement of the items. Defaults to `multi-browse`.
+ *
+ * Reach for `uncontained` when item aspect ratios have to be preserved: it
+ * hands items the width you asked for and does not snap.
+ */
+ layout?: CarouselLayout;
+ /**
+ * Where a `hero` layout puts its large item. `center` falls back to `start`
+ * below three items. Ignored by the other layouts.
+ */
+ alignment?: CarouselAlignment;
+ /**
+ * Overrides the layout's fling behaviour.
+ */
+ snap?: CarouselSnap;
+ /**
+ * Width to lay large items out at, for `multi-browse` and `uncontained`.
+ * Defaults to a square item. `hero` derives its own width from the container
+ * and `full-screen` always fills it.
+ */
+ itemWidth?: number;
+ /**
+ * Gap between items.
+ */
+ itemSpacing?: number;
+ /**
+ * Draws each item with a one-pixel outline.
+ */
+ outlined?: boolean;
+ /**
+ * Disables scrolling and renders the disabled visual state.
+ */
+ disabled?: boolean;
+ /**
+ * Item to rest on when the carousel is first laid out.
+ */
+ initialIndex?: number;
+ /**
+ * Called with the focal item's index once scrolling settles.
+ */
+ onIndexChange?: (index: number) => void;
+ /**
+ * Called when an item is pressed. Items are only hoverable, focusable and
+ * pressable when this is set.
+ */
+ onItemPress?: (item: ItemT, index: number) => void;
+ keyExtractor?: (item: ItemT, index: number) => string;
+ style?: StyleProp;
+ contentContainerStyle?: StyleProp;
+ testID?: string;
+ /**
+ * @optional
+ */
+ theme?: ThemeProp;
+ ref?: React.Ref;
+ /**
+ * Accessibility label for the carousel.
+ */
+ 'aria-label'?: string;
+};
+
+/**
+ * Carousels show a scrolling strip of items — images, most often — that grow
+ * and shrink as they pass through the container.
+ *
+ * Items are never resized. Every item is measured at the large size for the
+ * whole of its life; the size change you see is an interpolated mask rectangle
+ * plus a translation, with items stacked in descending order so a collapsing
+ * item passes under the one before it.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { Image } from 'react-native';
+ * import { Carousel, CarouselItem } from 'react-native-paper';
+ *
+ * const photos = [{ id: '1', uri: '…' }, { id: '2', uri: '…' }];
+ *
+ * const MyComponent = () => (
+ * photo.id}
+ * renderItem={({ item, mask }) => (
+ *
+ *
+ *
+ * )}
+ * />
+ * );
+ *
+ * export default MyComponent;
+ * ```
+ *
+ * ## Theming
+ * Customize by overriding these `theme.colors` roles:
+ * - `surfaceContainerHigh`: item container, behind the content
+ * - `outlineVariant`: the `outlined` item border
+ * - `onSurface`: hover, focus and press state layers
+ * - `secondary`: focus indicator
+ *
+ * The item corner radius comes from `theme.shapes.corner.extraLarge`.
+ *
+ * Note: layout is left-to-right. Right-to-left locales are not handled yet.
+ */
+const Carousel = ({
+ data,
+ renderItem,
+ height,
+ layout = 'multi-browse',
+ alignment = 'start',
+ snap,
+ itemWidth,
+ itemSpacing = 0,
+ outlined = false,
+ disabled = false,
+ initialIndex = 0,
+ onIndexChange,
+ onItemPress,
+ keyExtractor,
+ style,
+ contentContainerStyle,
+ testID,
+ theme: themeOverrides,
+ ref,
+ 'aria-label': ariaLabel,
+}: Props) => {
+ const theme = useInternalTheme(themeOverrides);
+ const reduceMotion = useReduceMotion();
+ const scrollRef = useAnimatedRef();
+ const scrollX = useSharedValue(0);
+
+ // Settle state. `settle` is the spring's output, written into the scroll view
+ // on the UI thread while `settling` is set; a new touch clears both, which is
+ // what makes the settle interruptible.
+ const settle = useSharedValue(0);
+ const settling = useSharedValue(false);
+ const dragStart = useSharedValue(0);
+ // Velocity is tracked here rather than read from the drag-end event: the
+ // event's units and sign differ between platforms, whereas successive offsets
+ // do not.
+ const velocity = useSharedValue(0);
+ const lastOffset = useSharedValue(0);
+ const lastTimestamp = useSharedValue(0);
+
+ const [containerWidth, setContainerWidth] = React.useState(0);
+ const [windowStart, setWindowStart] = React.useState(initialIndex);
+
+ const itemCount = data.length;
+ const strategy = React.useMemo(
+ () =>
+ createStrategy({
+ layout,
+ alignment,
+ containerSize: containerWidth,
+ containerHeight: height,
+ preferredItemSize: itemWidth ?? height,
+ itemCount,
+ snap,
+ }),
+ [layout, alignment, containerWidth, height, itemWidth, itemCount, snap]
+ );
+
+ const colors = React.useMemo(() => getDefaultCarouselColors(theme), [theme]);
+
+ const itemSize = strategy?.itemSize ?? 0;
+
+ const lastReportedIndex = React.useRef(initialIndex);
+ const reportIndex = React.useCallback(
+ (index: number) => {
+ if (!onIndexChange) return;
+ const clamped = Math.min(Math.max(index, 0), Math.max(itemCount - 1, 0));
+ if (clamped === lastReportedIndex.current) return;
+ lastReportedIndex.current = clamped;
+ onIndexChange(clamped);
+ },
+ [onIndexChange, itemCount]
+ );
+
+ // The settle is scroll-driven and interruptible, so it belongs in the
+ // imperative tier: a shared value sprung on the M3 spatial spring rather than
+ // the scroll view's own deceleration curve.
+ const springConfig = React.useMemo(
+ () => ({
+ ...toRawSpring(theme.motion.spring.default.spatial),
+ reduceMotion: reduceMotion ? ReduceMotion.Always : ReduceMotion.Never,
+ }),
+ [theme.motion.spring.default.spatial, reduceMotion]
+ );
+
+ const springTo = React.useCallback(
+ (target: number) => {
+ 'worklet';
+ settling.value = true;
+ settle.value = scrollX.value;
+ settle.value = withSpring(target, springConfig, (finished) => {
+ if (finished) {
+ settling.value = false;
+ if (itemSize > 0) {
+ runOnJS(reportIndex)(Math.round(target / itemSize));
+ }
+ }
+ });
+ },
+ [springConfig, itemSize, reportIndex, settle, settling, scrollX]
+ );
+
+ // Drive the scroll view from the spring while it runs.
+ useAnimatedReaction(
+ () => (settling.value ? settle.value : null),
+ (offset) => {
+ if (offset !== null) {
+ scrollTo(scrollRef, offset, 0, false);
+ }
+ }
+ );
+
+ const scrollHandler = useAnimatedScrollHandler(
+ {
+ onScroll: (event) => {
+ const offset = event.contentOffset.x;
+ const now = performance.now();
+ const elapsed = now - lastTimestamp.value;
+ // Ignore stale gaps; a resumed scroll would otherwise read as a fling.
+ if (elapsed > 0 && elapsed < 100) {
+ const sample = ((offset - lastOffset.value) / elapsed) * 1000;
+ velocity.value = velocity.value * 0.7 + sample * 0.3;
+ }
+ lastOffset.value = offset;
+ lastTimestamp.value = now;
+ scrollX.value = offset;
+ },
+ onBeginDrag: (event) => {
+ // A new touch wins over an in-flight settle.
+ cancelAnimation(settle);
+ settling.value = false;
+ velocity.value = 0;
+ dragStart.value = event.contentOffset.x;
+ },
+ onEndDrag: (event) => {
+ if (!strategy) return;
+ const target = resolveSettleOffset(
+ strategy,
+ event.contentOffset.x,
+ dragStart.value,
+ velocity.value
+ );
+ if (target !== null) {
+ springTo(target);
+ }
+ },
+ },
+ [strategy, springTo]
+ );
+
+ // Only an uncontained carousel still decelerates natively; everywhere else
+ // the settle spring reports the index from its own completion.
+ const handleMomentumEnd = (
+ event: NativeSyntheticEvent
+ ) => {
+ if (itemSize > 0) {
+ reportIndex(Math.round(event.nativeEvent.contentOffset.x / itemSize));
+ }
+ };
+
+ useAnimatedReaction(
+ () => (itemSize > 0 ? Math.floor(scrollX.value / itemSize) : 0),
+ (current, previous) => {
+ if (current !== previous) {
+ runOnJS(setWindowStart)(current);
+ }
+ },
+ [itemSize]
+ );
+
+ const scrollToIndex = React.useCallback(
+ (index: number, animated = true) => {
+ if (!strategy) return;
+ const clamped = Math.min(Math.max(index, 0), Math.max(itemCount - 1, 0));
+ const target = Math.min(clamped * strategy.itemSize, strategy.maxScroll);
+ if (animated) {
+ // Programmatic moves settle on the same spring as a fling.
+ runOnUI(springTo)(target);
+ } else {
+ scrollRef.current?.scrollTo({ x: target, animated: false });
+ reportIndex(clamped);
+ }
+ },
+ [strategy, itemCount, springTo, scrollRef, reportIndex]
+ );
+
+ React.useImperativeHandle(ref, () => ({ scrollToIndex }), [scrollToIndex]);
+
+ // Jump to the requested item as soon as the container has been measured.
+ const appliedInitialIndex = React.useRef(false);
+ React.useEffect(() => {
+ if (!strategy || appliedInitialIndex.current || initialIndex <= 0) return;
+ appliedInitialIndex.current = true;
+ scrollToIndex(initialIndex, false);
+ }, [strategy, initialIndex, scrollToIndex]);
+
+ const handleLayout = (event: LayoutChangeEvent) => {
+ setContainerWidth(event.nativeEvent.layout.width);
+ };
+
+ const snapProps = React.useMemo(
+ () => (strategy ? getSnapScrollProps(strategy) : {}),
+ [strategy]
+ );
+
+ const visible = React.useMemo(() => {
+ if (!strategy) return { from: 0, to: 0 };
+ const slots = visibleSlotCount(strategy);
+ return {
+ from: Math.max(windowStart - OVERSCAN, 0),
+ to: Math.min(windowStart + slots + OVERSCAN, itemCount),
+ };
+ }, [strategy, windowStart, itemCount]);
+
+ return (
+
+ {strategy ? (
+ <>
+ {data.slice(visible.from, visible.to).map((item, offset) => {
+ const index = visible.from + offset;
+ return (
+
+ );
+ })}
+ >
+ ) : null}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ overflow: 'hidden',
+ },
+});
+
+export default Carousel;
diff --git a/src/components/Carousel/CarouselItem.tsx b/src/components/Carousel/CarouselItem.tsx
new file mode 100644
index 0000000000..bff99719d1
--- /dev/null
+++ b/src/components/Carousel/CarouselItem.tsx
@@ -0,0 +1,115 @@
+import * as React from 'react';
+import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
+
+import Animated, {
+ interpolate,
+ useAnimatedStyle,
+ type AnimatedStyle,
+} from 'react-native-reanimated';
+
+import type { CarouselItemMask } from './types';
+import { useLocale } from '../../core/locale';
+
+/** Below this much of its full width, an item's content has faded out entirely. */
+const FADE_OUT_EXPANSION = 0.5;
+
+export type Props = {
+ /**
+ * The item's live mask, from `renderItem`.
+ */
+ mask: CarouselItemMask;
+ children?: React.ReactNode;
+ style?: StyleProp>;
+ testID?: string;
+};
+
+/**
+ * An item's full-size box.
+ *
+ * Content placed here is laid out at the item's unmasked size and clipped by
+ * the mask, which is what keeps media from reflowing as the item moves through
+ * the keylines. Content that should follow the mask instead — a label, most
+ * often — goes in a `CarouselItemContent` inside this box.
+ *
+ * ## Usage
+ * ```js
+ * import * as React from 'react';
+ * import { Image } from 'react-native';
+ * import { Carousel, CarouselItem, CarouselItemContent, Text } from 'react-native-paper';
+ *
+ * const MyComponent = () => (
+ * (
+ *
+ *
+ *
+ * {item.title}
+ *
+ *
+ * )}
+ * />
+ * );
+ *
+ * export default MyComponent;
+ * ```
+ */
+export const CarouselItem = ({ mask, children, style, testID }: Props) => (
+
+ {children}
+
+);
+
+/**
+ * Content that tracks the mask rather than the item.
+ *
+ * It pins to the mask's leading edge and fades out as the item collapses,
+ * which is the behaviour the spec asks of an item's text. Both reference
+ * implementations expose the live mask for exactly this — `OnMaskChangedListener`
+ * on Android, `carouselItemDrawInfo.maskRect` in Compose.
+ */
+export const CarouselItemContent = ({
+ mask,
+ children,
+ style,
+ testID,
+}: Props) => {
+ const { direction } = useLocale();
+ const { width } = mask;
+
+ const animatedStyle = useAnimatedStyle(() => {
+ const { left, right } = mask.rect.value;
+ return {
+ transform: [{ translateX: direction === 'rtl' ? right - width : left }],
+ opacity: interpolate(
+ mask.expansion.value,
+ [FADE_OUT_EXPANSION, 1],
+ [0, 1],
+ 'clamp'
+ ),
+ };
+ }, [direction, width]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+const styles = StyleSheet.create({
+ content: {
+ position: 'absolute',
+ left: 0,
+ bottom: 0,
+ },
+});
+
+export default CarouselItem;
diff --git a/src/components/Carousel/CarouselItemShell.tsx b/src/components/Carousel/CarouselItemShell.tsx
new file mode 100644
index 0000000000..0624d0cb1a
--- /dev/null
+++ b/src/components/Carousel/CarouselItemShell.tsx
@@ -0,0 +1,316 @@
+import * as React from 'react';
+import { Platform, Pressable, StyleSheet, View } from 'react-native';
+import type { ColorValue, ViewStyle } from 'react-native';
+
+import Animated, {
+ cubicBezier,
+ useAnimatedStyle,
+ useDerivedValue,
+ type AnimatedStyle,
+ type SharedValue,
+} from 'react-native-reanimated';
+
+import type { CarouselStrategy } from './strategy';
+import { CarouselTokens } from './tokens';
+import type { CarouselMaskRect, CarouselRenderItemInfo } from './types';
+import { resolveItemPlacement, type CarouselColors } from './utils';
+import { useInternalTheme } from '../../core/theming';
+import { tokens } from '../../theme/tokens';
+import type { InternalTheme } from '../../theme/types';
+import { isKeyboardFocusEvent } from '../../utils/isKeyboardFocusEvent';
+import Surface from '../Surface';
+
+const { opacity: stateOpacity, focusIndicator } = tokens.md.sys.state;
+const FOCUS_INSET = focusIndicator.thickness + focusIndicator.outerOffset;
+
+export type CarouselItemShellProps = {
+ item: ItemT;
+ index: number;
+ itemCount: number;
+ renderItem: (info: CarouselRenderItemInfo) => React.ReactNode;
+ strategy: CarouselStrategy;
+ scrollX: SharedValue;
+ height: number;
+ spacing: number;
+ colors: CarouselColors;
+ outlined: boolean;
+ disabled: boolean;
+ onPress?: (item: ItemT, index: number) => void;
+ theme: InternalTheme;
+};
+
+/**
+ * One item's box, mask and interaction states.
+ *
+ * The box is always the full unmasked item size; what changes as the item
+ * moves through the keylines is the clipping wrapper inside it and a
+ * translation. The corner radius lives on that wrapper — the mask — rather
+ * than on the box, so the rounded corners travel with the visible rectangle
+ * instead of with the item.
+ */
+function CarouselItemShell({
+ item,
+ index,
+ itemCount,
+ renderItem,
+ strategy,
+ scrollX,
+ height,
+ spacing,
+ colors,
+ outlined,
+ disabled,
+ onPress,
+ theme: themeOverride,
+}: CarouselItemShellProps) {
+ const theme = useInternalTheme(themeOverride);
+ const [hovered, setHovered] = React.useState(false);
+ const [pressed, setPressed] = React.useState(false);
+ const [focused, setFocused] = React.useState(false);
+
+ const { itemSize } = strategy;
+ const contentWidth = Math.max(itemSize - spacing, 0);
+ const borderRadius = theme.shapes.corner[CarouselTokens.containerShape];
+
+ const placement = useDerivedValue(
+ () => resolveItemPlacement(strategy, scrollX.value, index),
+ [strategy, index]
+ );
+
+ // Item-local mask, measured from the visible item box rather than from the
+ // spacing-inflated slot, which is what `renderItem` wants to align against.
+ const maskRect = useDerivedValue(() => {
+ const left = (itemSize - placement.value.size) / 2;
+ const width = Math.max(placement.value.size - spacing, 0);
+ return { left, top: 0, right: left + width, bottom: height };
+ }, [itemSize, spacing, height]);
+
+ const expansion = useDerivedValue(() => {
+ if (contentWidth <= 0) {
+ return 0;
+ }
+ const width = maskRect.value.right - maskRect.value.left;
+ return Math.min(Math.max(width / contentWidth, 0), 1);
+ }, [contentWidth]);
+
+ const boxStyle = useAnimatedStyle(
+ () => ({
+ // The box is drawn in container coordinates but lives inside the scrolling
+ // content, so the scroll offset is added back in.
+ transform: [
+ { translateX: scrollX.value + placement.value.center - itemSize / 2 },
+ ],
+ }),
+ [itemSize]
+ );
+
+ const maskStyle = useAnimatedStyle(() => {
+ const { left, right } = maskRect.value;
+ return {
+ width: right - left,
+ transform: [{ translateX: left + spacing / 2 }],
+ };
+ }, [spacing]);
+
+ const contentStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: -maskRect.value.left }],
+ }));
+
+ const focusRingStyle = useAnimatedStyle(() => {
+ const { left, right } = maskRect.value;
+ return {
+ width: right - left + FOCUS_INSET * 2,
+ transform: [{ translateX: left + spacing / 2 - FOCUS_INSET }],
+ };
+ }, [spacing]);
+
+ const transition = useTransition(theme);
+
+ const interactive = onPress !== undefined && !disabled;
+ const stateLayerOpacity = !interactive
+ ? 0
+ : pressed
+ ? stateOpacity.pressed
+ : focused
+ ? stateOpacity.focused
+ : hovered
+ ? stateOpacity.hovered
+ : 0;
+ const stateLayerColor: ColorValue = pressed
+ ? colors.pressedStateLayerColor
+ : focused
+ ? colors.focusStateLayerColor
+ : colors.hoverStateLayerColor;
+ const focusRingOpacity = { opacity: focused && interactive ? 1 : 0 };
+ const elevation = !interactive
+ ? CarouselTokens.containerElevation
+ : pressed
+ ? CarouselTokens.pressedContainerElevation
+ : focused
+ ? CarouselTokens.focusContainerElevation
+ : hovered
+ ? CarouselTokens.hoverContainerElevation
+ : CarouselTokens.containerElevation;
+
+ const mask = React.useMemo(
+ () => ({ rect: maskRect, expansion, width: contentWidth, height }),
+ [maskRect, expansion, contentWidth, height]
+ );
+
+ const content = (
+
+ {renderItem({ item, index, mask })}
+
+ );
+
+ return (
+
+
+
+ {content}
+ {interactive ? (
+ onPress(item, index)}
+ onPressIn={() => setPressed(true)}
+ onPressOut={() => setPressed(false)}
+ onHoverIn={() => setHovered(true)}
+ onHoverOut={() => setHovered(false)}
+ onFocus={(event) => {
+ if (!isKeyboardFocusEvent(event)) return;
+ setFocused(true);
+ }}
+ onBlur={() => setFocused(false)}
+ role="button"
+ aria-disabled={disabled}
+ />
+ ) : null}
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Fades, colour changes and the elevation rise are not scroll-driven, so they
+ * run as CSS transitions rather than through the imperative tier.
+ */
+function useTransition(theme: InternalTheme): AnimatedStyle {
+ return React.useMemo(
+ () => ({
+ transitionProperty: ['opacity', 'backgroundColor'],
+ transitionDuration: theme.motion.duration.short3 * theme.animation.scale,
+ transitionTimingFunction: cubicBezier(...theme.motion.easing.standard),
+ }),
+ [
+ theme.motion.duration.short3,
+ theme.motion.easing.standard,
+ theme.animation.scale,
+ ]
+ );
+}
+
+const styles = StyleSheet.create({
+ box: {
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ },
+ mask: {
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ },
+ clip: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ overflow: 'hidden',
+ },
+ content: {
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ },
+ ignoreTouches: {
+ pointerEvents: 'none',
+ },
+ stateLayer: {
+ pointerEvents: 'none',
+ },
+ focusRing: {
+ position: 'absolute',
+ left: 0,
+ pointerEvents: 'none',
+ },
+});
+
+// Web-only style; not in StyleSheet because `outline` is outside ViewStyle.
+// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+const webNoOutline = { outline: 'none' } as unknown as ViewStyle;
+
+export default CarouselItemShell;
diff --git a/src/components/Carousel/index.ts b/src/components/Carousel/index.ts
new file mode 100644
index 0000000000..b39f63fae9
--- /dev/null
+++ b/src/components/Carousel/index.ts
@@ -0,0 +1,16 @@
+export { default } from './Carousel';
+export type { Props, CarouselHandle } from './Carousel';
+export {
+ CarouselItem,
+ CarouselItemContent,
+ type Props as CarouselItemProps,
+} from './CarouselItem';
+export { CarouselGeometry, CarouselTokens } from './tokens';
+export type {
+ CarouselAlignment,
+ CarouselItemMask,
+ CarouselLayout,
+ CarouselMaskRect,
+ CarouselRenderItemInfo,
+ CarouselSnap,
+} from './types';
diff --git a/src/components/Carousel/strategy.ts b/src/components/Carousel/strategy.ts
new file mode 100644
index 0000000000..43b6727612
--- /dev/null
+++ b/src/components/Carousel/strategy.ts
@@ -0,0 +1,495 @@
+import { CarouselGeometry } from './tokens';
+import type { CarouselAlignment, CarouselLayout, CarouselSnap } from './types';
+
+const {
+ smallSizeMin: SMALL_MIN,
+ smallSizeMax: SMALL_MAX,
+ anchorSize: ANCHOR,
+ goneSize: GONE,
+ heroLargeMaxAspectRatio: HERO_MAX_ASPECT,
+ heroCentreAlignedMinItemCount: HERO_CENTRE_MIN_ITEMS,
+ heroLargeCountMax: HERO_LARGE_COUNT_MAX,
+ uncontainedMediumThreshold: UNCONTAINED_MEDIUM_THRESHOLD,
+} = CarouselGeometry;
+
+const clamp = (value: number, min: number, max: number) =>
+ Math.min(Math.max(value, min), max);
+
+/**
+ * One keyline state: the masked size an item takes at each stop across the
+ * container, and where that stop is drawn.
+ *
+ * Both arrays include the two off-container keylines at either end — an anchor
+ * and a gone keyline — so an item shrinks to a sliver at the edge rather than
+ * popping out of existence.
+ */
+export type KeylineState = {
+ /** Masked size at each keyline. */
+ sizes: number[];
+ /** Centre of each keyline, in container coordinates. */
+ centers: number[];
+ /** Index of the first focal (large) keyline. */
+ focalStart: number;
+ /** Index of the last focal (large) keyline. */
+ focalEnd: number;
+};
+
+/**
+ * Everything the render pass needs to place an item at a given scroll offset.
+ *
+ * Items are laid out on an *unmasked* axis where every item occupies
+ * `itemSize`, and the keylines map a position on that axis onto a drawn centre
+ * and a masked size. `startState` and `endState` are the same keylines
+ * permuted so the focal range sits hard against the start or the end of the
+ * container; the carousel blends into them over the first and last
+ * `startShift` / `endShift` of scroll so the first and last items can be
+ * focal.
+ */
+export type CarouselStrategy = {
+ /** The unmasked item size — what every item is measured at. */
+ itemSize: number;
+ focalCount: number;
+ startState: KeylineState;
+ defaultState: KeylineState;
+ endState: KeylineState;
+ startShift: number;
+ endShift: number;
+ maxScroll: number;
+ contentSize: number;
+ snap: CarouselSnap;
+};
+
+/**
+ * Lays keylines out across the container.
+ *
+ * `sizes` are the in-container keylines and must sum to `containerSize`; the
+ * anchor and gone keylines are added outside either edge.
+ */
+function buildState(
+ sizes: number[],
+ focalStart: number,
+ focalEnd: number,
+ containerSize: number
+): KeylineState {
+ const allSizes = [GONE, ANCHOR, ...sizes, ANCHOR, GONE];
+
+ const centers: number[] = [-(ANCHOR + GONE / 2), -ANCHOR / 2];
+ let edge = 0;
+ for (const size of sizes) {
+ centers.push(edge + size / 2);
+ edge += size;
+ }
+ centers.push(containerSize + ANCHOR / 2);
+ centers.push(containerSize + ANCHOR + GONE / 2);
+
+ return {
+ sizes: allSizes,
+ centers,
+ focalStart: focalStart + 2,
+ focalEnd: focalEnd + 2,
+ };
+}
+
+/**
+ * Builds the default, start-shifted and end-shifted keyline states from one
+ * in-container arrangement.
+ *
+ * The shifted states are permutations of the same sizes, so the three states
+ * always have the same keyline count and can be interpolated pairwise. Items
+ * move outward from the focal range in both directions, which is why the
+ * leading keylines are reversed when they are moved behind it.
+ */
+function buildStates(
+ sizes: number[],
+ focalStart: number,
+ focalEnd: number,
+ containerSize: number,
+ itemSize: number,
+ itemCount: number,
+ snap: CarouselSnap
+): CarouselStrategy {
+ const before = sizes.slice(0, focalStart);
+ const focal = sizes.slice(focalStart, focalEnd + 1);
+ const after = sizes.slice(focalEnd + 1);
+ const outward = [...after, ...before.slice().reverse()];
+
+ const defaultState = buildState(sizes, focalStart, focalEnd, containerSize);
+ const startState = buildState(
+ [...focal, ...outward],
+ 0,
+ focal.length - 1,
+ containerSize
+ );
+ const endState = buildState(
+ [...outward.slice().reverse(), ...focal],
+ outward.length,
+ outward.length + focal.length - 1,
+ containerSize
+ );
+
+ const focalCount = focal.length;
+ // Scroll is measured on the unmasked axis, so shifting the focal range by one
+ // keyline is worth exactly one item of scroll.
+ const startShift =
+ (defaultState.focalStart - startState.focalStart) * itemSize;
+ const endShift = (endState.focalStart - defaultState.focalStart) * itemSize;
+ const maxScroll = Math.max((itemCount - focalCount) * itemSize, 0);
+
+ return {
+ itemSize,
+ focalCount,
+ startState,
+ defaultState,
+ endState,
+ startShift,
+ endShift,
+ maxScroll,
+ contentSize: maxScroll + containerSize,
+ snap,
+ };
+}
+
+type Targets = {
+ targetSmall: number;
+ targetMedium: number;
+ targetLarge: number;
+};
+
+type Arrangement = {
+ smallCount: number;
+ smallSize: number;
+ mediumCount: number;
+ mediumSize: number;
+ largeCount: number;
+ largeSize: number;
+ cost: number;
+};
+
+/**
+ * Fits one candidate arrangement to the container exactly.
+ *
+ * Small items flex first, inside their 40–56dp band; the large items then
+ * absorb whatever is left. Because a medium item is defined as the mean of a
+ * large and a small one, that second step has a closed form.
+ */
+function fitArrangement(
+ available: number,
+ { targetSmall, targetMedium, targetLarge }: Targets,
+ smallCount: number,
+ mediumCount: number,
+ largeCount: number,
+ priority: number
+): Arrangement | null {
+ if (largeCount < 1) {
+ return null;
+ }
+
+ const naturalSpace =
+ largeCount * targetLarge +
+ mediumCount * targetMedium +
+ smallCount * targetSmall;
+ const delta = available - naturalSpace;
+
+ const smallSize =
+ smallCount > 0
+ ? clamp(targetSmall + delta / smallCount, SMALL_MIN, SMALL_MAX)
+ : 0;
+
+ const largeSize =
+ (available - smallSize * (smallCount + mediumCount / 2)) /
+ (largeCount + mediumCount / 2);
+ const mediumSize = mediumCount > 0 ? (largeSize + smallSize) / 2 : 0;
+
+ if (largeSize <= 0) {
+ return null;
+ }
+ if (smallCount > 0 && largeSize < smallSize) {
+ return null;
+ }
+ if (mediumCount > 0 && mediumSize < smallSize) {
+ return null;
+ }
+
+ return {
+ smallCount,
+ smallSize,
+ mediumCount,
+ mediumSize,
+ largeCount,
+ largeSize,
+ cost: Math.abs(targetLarge - largeSize) * priority,
+ };
+}
+
+/**
+ * Picks the arrangement whose large items land closest to the requested size,
+ * weighted by how far down the preference order the candidate sits.
+ */
+function findLowestCostArrangement(
+ available: number,
+ targets: Targets,
+ smallCounts: number[],
+ mediumCounts: number[],
+ largeCounts: number[],
+ itemCount: number
+): Arrangement | null {
+ let best: Arrangement | null = null;
+ let priority = 1;
+
+ for (const smallCount of smallCounts) {
+ for (const mediumCount of mediumCounts) {
+ for (const largeCount of largeCounts) {
+ const candidate =
+ smallCount + mediumCount + largeCount > itemCount
+ ? null
+ : fitArrangement(
+ available,
+ targets,
+ smallCount,
+ mediumCount,
+ largeCount,
+ priority
+ );
+ priority += 1;
+ if (candidate && (best === null || candidate.cost < best.cost)) {
+ best = candidate;
+ }
+ }
+ }
+ }
+
+ return best;
+}
+
+function toSizes(arrangement: Arrangement): number[] {
+ return [
+ ...new Array(arrangement.largeCount).fill(arrangement.largeSize),
+ ...new Array(arrangement.mediumCount).fill(arrangement.mediumSize),
+ ...new Array(arrangement.smallCount).fill(arrangement.smallSize),
+ ];
+}
+
+function descendingRange(from: number, to: number): number[] {
+ const values: number[] = [];
+ for (let value = from; value >= to; value--) {
+ values.push(value);
+ }
+ return values;
+}
+
+function multiBrowseSizes(
+ available: number,
+ preferredItemSize: number,
+ itemCount: number
+): { sizes: number[]; focalEnd: number } {
+ const targetLarge = Math.min(preferredItemSize, available);
+ const targetSmall = clamp(targetLarge / 3, SMALL_MIN, SMALL_MAX);
+ const targets: Targets = {
+ targetLarge,
+ targetSmall,
+ targetMedium: (targetLarge + targetSmall) / 2,
+ };
+
+ // Multi-browse always keeps a small item — it is what tells the reader the
+ // strip continues — so the only reason to drop it is a container too narrow
+ // to hold one. Letting the solver choose 0 smalls would win on large-size
+ // cost and quietly turn the layout into a plain pager.
+ const smallCounts = available < SMALL_MIN * 2 ? [0] : [1];
+ const mediumCounts = [1, 0];
+ const largeCounts = descendingRange(
+ Math.max(1, Math.floor(available / targetLarge)),
+ 1
+ );
+
+ const arrangement =
+ findLowestCostArrangement(
+ available,
+ targets,
+ smallCounts,
+ mediumCounts,
+ largeCounts,
+ itemCount
+ ) ??
+ // Nothing fit within the item count — fall back to filling the container
+ // with as many large items as there are items to put in it.
+ ({
+ smallCount: 0,
+ smallSize: 0,
+ mediumCount: 0,
+ mediumSize: 0,
+ largeCount: Math.max(1, Math.min(itemCount, largeCounts[0])),
+ largeSize: available / Math.max(1, Math.min(itemCount, largeCounts[0])),
+ cost: 0,
+ } satisfies Arrangement);
+
+ return { sizes: toSizes(arrangement), focalEnd: arrangement.largeCount - 1 };
+}
+
+function heroSizes(
+ available: number,
+ containerHeight: number,
+ itemCount: number,
+ alignment: CarouselAlignment
+): { sizes: number[]; focalStart: number; focalEnd: number } {
+ const centred = alignment === 'center' && itemCount >= HERO_CENTRE_MIN_ITEMS;
+ const smallCount = Math.min(centred ? 2 : 1, Math.max(itemCount - 1, 0));
+
+ // A hero's small peeks saturate their band on any realistic container, but
+ // resolve them through the same large/3 target the other layouts use.
+ const provisionalLarge = Math.max(
+ available - smallCount * SMALL_MAX,
+ SMALL_MAX
+ );
+ const smallSize =
+ smallCount > 0 ? clamp(provisionalLarge / 3, SMALL_MIN, SMALL_MAX) : 0;
+
+ const spaceForLarge = available - smallCount * smallSize;
+ const maxLargeWidth = HERO_MAX_ASPECT * containerHeight;
+ const largeCount = clamp(
+ Math.ceil(spaceForLarge / Math.max(maxLargeWidth, 1)),
+ 1,
+ Math.min(HERO_LARGE_COUNT_MAX, Math.max(itemCount - smallCount, 1))
+ );
+ const largeSize = spaceForLarge / largeCount;
+
+ const large = new Array(largeCount).fill(largeSize);
+
+ if (smallCount === 2) {
+ return {
+ sizes: [smallSize, ...large, smallSize],
+ focalStart: 1,
+ focalEnd: largeCount,
+ };
+ }
+
+ return {
+ sizes: [...large, ...new Array(smallCount).fill(smallSize)],
+ focalStart: 0,
+ focalEnd: largeCount - 1,
+ };
+}
+
+function uncontainedSizes(
+ available: number,
+ preferredItemSize: number,
+ itemCount: number
+): { sizes: number[]; focalEnd: number } {
+ const requested = Math.min(preferredItemSize, available);
+ const largeCount = Math.max(
+ 1,
+ Math.min(Math.floor(available / requested), itemCount)
+ );
+
+ let largeSize = requested;
+ let mediumSize = available - largeCount * largeSize;
+
+ if (mediumSize / largeSize > UNCONTAINED_MEDIUM_THRESHOLD) {
+ // The leftover strip is close enough to a full item that it stops reading
+ // as a peek. Cap it and let the large items take up the slack — this is the
+ // one case where uncontained does not hand items their requested size.
+ mediumSize = largeSize * UNCONTAINED_MEDIUM_THRESHOLD;
+ largeSize = (available - mediumSize) / largeCount;
+ }
+
+ if (mediumSize <= 0 || itemCount <= largeCount) {
+ // No cut-off item, so the large items have to fill the container on their own.
+ largeSize = available / largeCount;
+ return {
+ sizes: new Array(largeCount).fill(largeSize),
+ focalEnd: largeCount - 1,
+ };
+ }
+
+ return {
+ sizes: [...new Array(largeCount).fill(largeSize), mediumSize],
+ focalEnd: largeCount - 1,
+ };
+}
+
+export type StrategyOptions = {
+ layout: CarouselLayout;
+ alignment: CarouselAlignment;
+ /** Container width available to the carousel. */
+ containerSize: number;
+ containerHeight: number;
+ /** Item size the caller asked for. Ignored by `full-screen`. */
+ preferredItemSize: number;
+ itemCount: number;
+ snap?: CarouselSnap;
+};
+
+const DEFAULT_SNAP: Record = {
+ // Multi-browse lets momentum decay across several items before it settles.
+ 'multi-browse': 'multi',
+ // Hero and full-screen advance by exactly one item per fling.
+ hero: 'single',
+ 'full-screen': 'single',
+ // Uncontained does not snap at all; that is what preserves aspect ratios.
+ uncontained: 'none',
+};
+
+/**
+ * Resolves a layout into the keyline states the carousel scrolls through.
+ *
+ * Returns `null` until the container has been measured.
+ */
+export function createStrategy({
+ layout,
+ alignment,
+ containerSize,
+ containerHeight,
+ preferredItemSize,
+ itemCount,
+ snap,
+}: StrategyOptions): CarouselStrategy | null {
+ if (containerSize <= 0 || itemCount <= 0) {
+ return null;
+ }
+
+ const resolvedSnap = snap ?? DEFAULT_SNAP[layout];
+
+ if (layout === 'full-screen') {
+ return buildStates(
+ [containerSize],
+ 0,
+ 0,
+ containerSize,
+ containerSize,
+ itemCount,
+ resolvedSnap
+ );
+ }
+
+ if (layout === 'hero') {
+ const { sizes, focalStart, focalEnd } = heroSizes(
+ containerSize,
+ containerHeight,
+ itemCount,
+ alignment
+ );
+ return buildStates(
+ sizes,
+ focalStart,
+ focalEnd,
+ containerSize,
+ sizes[focalStart],
+ itemCount,
+ resolvedSnap
+ );
+ }
+
+ const { sizes, focalEnd } =
+ layout === 'uncontained'
+ ? uncontainedSizes(containerSize, preferredItemSize, itemCount)
+ : multiBrowseSizes(containerSize, preferredItemSize, itemCount);
+
+ return buildStates(
+ sizes,
+ 0,
+ focalEnd,
+ containerSize,
+ sizes[0],
+ itemCount,
+ resolvedSnap
+ );
+}
diff --git a/src/components/Carousel/tokens.ts b/src/components/Carousel/tokens.ts
new file mode 100644
index 0000000000..4a23da0426
--- /dev/null
+++ b/src/components/Carousel/tokens.ts
@@ -0,0 +1,84 @@
+import type { Elevation } from '../../theme/types';
+import type { ColorRole } from '../../theme/types';
+import type { ShapeToken } from '../../theme/utils/shape';
+
+/**
+ * `md.comp.carousel-item.*` — the carousel's entire spec surface.
+ *
+ * There is no `md.comp.carousel.*` group, no plural and no `.expressive.`
+ * variant, so baseline and Expressive resolve to the same values. The table is
+ * colour / shape / state only: it carries no geometry at all, which is why
+ * every size lives in `CarouselGeometry` below and is sourced from the
+ * MDC-Android and Compose implementations instead.
+ */
+const shape = {
+ containerShape: 'extraLarge',
+} as const satisfies Record;
+
+/**
+ * Two groups from the spec table have no consumer on this side and are
+ * deliberately not mirrored here:
+ *
+ * - `label-text.*`. MDC draws an item's label itself; in Paper the item's
+ * content — text included — is rendered by the caller, so there is nothing
+ * for a label colour to paint. `Text` already resolves to `onSurface`, the
+ * value the token carries, and the disabled state dims the content as a
+ * whole through `disabledContainerOpacity` rather than per-run.
+ * - `dragged.*`. A drag on a carousel item scrolls the strip, and React Native
+ * hands the responder to the scroll view, which ends the item's press. There
+ * is no interaction left for a dragged state to describe.
+ */
+const colors = {
+ containerColor: 'surfaceContainerHigh',
+ outlineColor: 'outlineVariant',
+ focusIndicatorColor: 'secondary',
+ hoverStateLayerColor: 'onSurface',
+ focusStateLayerColor: 'onSurface',
+ pressedStateLayerColor: 'onSurface',
+} as const satisfies Record;
+
+/** Hover is the only state that lifts an item off the surface. */
+const elevations = {
+ containerElevation: 0,
+ hoverContainerElevation: 1,
+ focusContainerElevation: 0,
+ pressedContainerElevation: 0,
+} as const satisfies Record;
+
+const dimensions = {
+ outlineWidth: 1,
+ disabledContainerOpacity: 0.38,
+} as const;
+
+export const CarouselTokens = {
+ ...shape,
+ ...colors,
+ ...elevations,
+ ...dimensions,
+};
+
+/**
+ * Geometry the token table does not define. Values come from the MDC-Android
+ * and Compose carousel implementations.
+ */
+export const CarouselGeometry = {
+ /** Smallest an item may be laid out at while still counted as a small item. */
+ smallSizeMin: 40,
+ /** Largest a small item may grow to before it reads as a medium item. */
+ smallSizeMax: 56,
+ /** Size an item shrinks to at the container edge, just before it goes. */
+ anchorSize: 10,
+ /** Size of the off-screen keyline an item disappears into. */
+ goneSize: 1,
+ /** A hero's large item is at most this many times its own height. */
+ heroLargeMaxAspectRatio: 2,
+ /** Centre-aligned hero falls back to start-aligned below this many items. */
+ heroCentreAlignedMinItemCount: 3,
+ /** Hero shows at most this many large items, however wide the container is. */
+ heroLargeCountMax: 2,
+ /**
+ * Above this fraction of the large size, an uncontained carousel's trailing
+ * cut-off item reads as a second full item rather than a peek.
+ */
+ uncontainedMediumThreshold: 0.85,
+} as const;
diff --git a/src/components/Carousel/types.ts b/src/components/Carousel/types.ts
new file mode 100644
index 0000000000..24e5526a10
--- /dev/null
+++ b/src/components/Carousel/types.ts
@@ -0,0 +1,68 @@
+import type { SharedValue } from 'react-native-reanimated';
+
+/**
+ * Which arrangement the carousel lays its items out in.
+ *
+ * - `multi-browse` shows one or two large items next to a medium and a small
+ * one, so several items are browsable at a glance.
+ * - `hero` puts the emphasis on a single large item with small items peeking
+ * at one or both sides.
+ * - `uncontained` keeps items at the size they were asked for and lets the
+ * last one run past the container edge. It is the layout to reach for when
+ * item aspect ratios have to be preserved.
+ * - `full-screen` shows one item filling the container.
+ */
+export type CarouselLayout =
+ | 'multi-browse'
+ | 'hero'
+ | 'uncontained'
+ | 'full-screen';
+
+/** Where the focal (large) item sits. Only `hero` reads this. */
+export type CarouselAlignment = 'start' | 'center';
+
+/**
+ * How a fling settles.
+ *
+ * - `single` advances by exactly one item per fling.
+ * - `multi` lets momentum decay across several items before springing to the
+ * nearest focal keyline.
+ * - `none` does not snap at all.
+ */
+export type CarouselSnap = 'single' | 'multi' | 'none';
+
+/**
+ * The visible rectangle of an item, in item-local coordinates.
+ *
+ * Items are never resized — every item is measured at the large size for the
+ * whole of its life — so this rectangle, not the item's box, is what changes
+ * as the item moves through the keylines.
+ */
+export type CarouselMaskRect = {
+ left: number;
+ top: number;
+ right: number;
+ bottom: number;
+};
+
+/**
+ * Live mask state handed to `renderItem`. `rect` and `expansion` are shared
+ * values written on the UI thread, so reading them from a `useAnimatedStyle`
+ * costs no re-renders.
+ */
+export type CarouselItemMask = {
+ /** The item's visible rectangle, in item-local coordinates. */
+ rect: SharedValue;
+ /** `0` when the item is fully collapsed, `1` when it is at full size. */
+ expansion: SharedValue;
+ /** Width every item is measured at, mask or no mask. */
+ width: number;
+ /** Height every item is measured at. */
+ height: number;
+};
+
+export type CarouselRenderItemInfo = {
+ item: ItemT;
+ index: number;
+ mask: CarouselItemMask;
+};
diff --git a/src/components/Carousel/utils.ts b/src/components/Carousel/utils.ts
new file mode 100644
index 0000000000..c870592040
--- /dev/null
+++ b/src/components/Carousel/utils.ts
@@ -0,0 +1,195 @@
+import type { ColorValue } from 'react-native';
+
+import type { CarouselStrategy, KeylineState } from './strategy';
+import { CarouselTokens } from './tokens';
+import type { InternalTheme } from '../../theme/types';
+
+export type CarouselColors = {
+ containerColor: ColorValue;
+ outlineColor: ColorValue;
+ focusIndicatorColor: ColorValue;
+ hoverStateLayerColor: ColorValue;
+ focusStateLayerColor: ColorValue;
+ pressedStateLayerColor: ColorValue;
+};
+
+export function getDefaultCarouselColors(theme: InternalTheme): CarouselColors {
+ const t = CarouselTokens;
+ const c = theme.colors;
+ return {
+ containerColor: c[t.containerColor],
+ outlineColor: c[t.outlineColor],
+ focusIndicatorColor: c[t.focusIndicatorColor],
+ hoverStateLayerColor: c[t.hoverStateLayerColor],
+ focusStateLayerColor: c[t.focusStateLayerColor],
+ pressedStateLayerColor: c[t.pressedStateLayerColor],
+ };
+}
+
+export type Placement = {
+ /** Where the item's centre is drawn, in container coordinates. */
+ center: number;
+ /** The item's masked size. */
+ size: number;
+};
+
+/**
+ * Reads a keyline state at a fractional keyline index.
+ *
+ * Indices outside the state clamp to its outermost keyline, which is the gone
+ * keyline — items that far out are off-screen anyway.
+ */
+function sampleKeylines(state: KeylineState, index: number): Placement {
+ 'worklet';
+ const count = state.sizes.length;
+ const clamped = Math.min(Math.max(index, 0), count - 1);
+ const lower = Math.min(Math.floor(clamped), count - 2);
+ const t = clamped - lower;
+
+ return {
+ center:
+ state.centers[lower] +
+ (state.centers[lower + 1] - state.centers[lower]) * t,
+ size:
+ state.sizes[lower] + (state.sizes[lower + 1] - state.sizes[lower]) * t,
+ };
+}
+
+/**
+ * Maps an item onto the keylines at the current scroll offset.
+ *
+ * Items live on an unmasked axis where item `i` is centred at
+ * `(i + 0.5) * itemSize`. Subtracting the scroll offset gives the position the
+ * keylines are read at, so scrolling to `i * itemSize` always puts item `i` on
+ * the first focal keyline — which is why the snap offsets are multiples of the
+ * item size rather than of the container width.
+ *
+ * Near either end the keylines blend into the shifted states, and the focal
+ * index is blended along with them so the mapping stays continuous.
+ */
+export function resolveItemPlacement(
+ strategy: CarouselStrategy,
+ scroll: number,
+ index: number
+): Placement {
+ 'worklet';
+ const {
+ itemSize,
+ startState,
+ defaultState,
+ endState,
+ startShift,
+ endShift,
+ maxScroll,
+ } = strategy;
+
+ let from = defaultState;
+ let to = defaultState;
+ let t = 0;
+
+ if (startShift > 0 && scroll < startShift) {
+ from = startState;
+ to = defaultState;
+ t = Math.min(Math.max(scroll / startShift, 0), 1);
+ } else if (endShift > 0 && scroll > maxScroll - endShift) {
+ from = defaultState;
+ to = endState;
+ t = Math.min(Math.max((scroll - (maxScroll - endShift)) / endShift, 0), 1);
+ }
+
+ const focalStart = from.focalStart + (to.focalStart - from.focalStart) * t;
+ const position = (index + 0.5) * itemSize - scroll;
+ const keylineIndex = position / itemSize - 0.5 + focalStart;
+
+ const a = sampleKeylines(from, keylineIndex);
+ const b = sampleKeylines(to, keylineIndex);
+
+ return {
+ center: a.center + (b.center - a.center) * t,
+ size: a.size + (b.size - a.size) * t,
+ };
+}
+
+/**
+ * How many keylines a strategy has inside and around the container — the
+ * number of items that can be on screen at once, used to size the render
+ * window.
+ */
+export function visibleSlotCount(strategy: CarouselStrategy): number {
+ return strategy.defaultState.sizes.length;
+}
+
+export type SnapScrollProps = {
+ decelerationRate: 'normal' | number;
+};
+
+/**
+ * Hands the settle to the platform or to us.
+ *
+ * Where the carousel snaps, native deceleration is switched off entirely: the
+ * scroll view stops dead when the finger lifts and the decay-plus-spring in
+ * `resolveSettleOffset` takes over, so the settle runs on the M3 spring rather
+ * than on the platform's own curve. An uncontained carousel does not snap, so
+ * it keeps native momentum.
+ */
+export function getSnapScrollProps(
+ strategy: CarouselStrategy
+): SnapScrollProps {
+ return { decelerationRate: strategy.snap === 'none' ? 'normal' : 0 };
+}
+
+/**
+ * How far a fling coasts, in seconds of its release velocity.
+ *
+ * This is the standard projection for a scroll view decelerating at 0.998 per
+ * millisecond: `v * rate / (1 - rate)`, which works out at roughly half a
+ * second of travel.
+ */
+const DECAY_PROJECTION = 0.5;
+
+/** A fling shorter than this fraction of an item is treated as a hold. */
+const SINGLE_ADVANCE_THRESHOLD = 0.15;
+
+/**
+ * Where a fling should come to rest.
+ *
+ * The three layouts settle differently, and the difference is entirely in this
+ * function — `single` advances by at most one item from wherever the drag
+ * started, `multi` lets the decay projection carry across as many items as the
+ * fling earned, and `none` does not settle at all.
+ *
+ * The target is always a focal keyline offset, never a container edge: on the
+ * unmasked axis those offsets are whole multiples of the item size.
+ *
+ * `velocity` is in points per second, positive towards the end of the list.
+ */
+export function resolveSettleOffset(
+ strategy: CarouselStrategy,
+ offset: number,
+ dragStartOffset: number,
+ velocity: number
+): number | null {
+ 'worklet';
+ const { snap, itemSize, maxScroll } = strategy;
+ if (snap === 'none' || itemSize <= 0) {
+ return null;
+ }
+
+ const projected = offset + velocity * DECAY_PROJECTION;
+
+ if (snap === 'multi') {
+ const index = Math.round(projected / itemSize);
+ return Math.min(Math.max(index * itemSize, 0), maxScroll);
+ }
+
+ const startIndex = Math.round(dragStartOffset / itemSize);
+ const travelled = (projected - dragStartOffset) / itemSize;
+ const step =
+ travelled > SINGLE_ADVANCE_THRESHOLD
+ ? 1
+ : travelled < -SINGLE_ADVANCE_THRESHOLD
+ ? -1
+ : 0;
+
+ return Math.min(Math.max((startIndex + step) * itemSize, 0), maxScroll);
+}
diff --git a/src/components/__tests__/Carousel.test.tsx b/src/components/__tests__/Carousel.test.tsx
new file mode 100644
index 0000000000..9e509dbc57
--- /dev/null
+++ b/src/components/__tests__/Carousel.test.tsx
@@ -0,0 +1,493 @@
+import { Text, View } from 'react-native';
+
+import { describe, expect, it, jest } from '@jest/globals';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { fireEvent, render, screen, userEvent } from '../../test-utils';
+import Carousel from '../Carousel/Carousel';
+import { CarouselItem, CarouselItemContent } from '../Carousel/CarouselItem';
+import { createStrategy, type CarouselStrategy } from '../Carousel/strategy';
+import { CarouselGeometry, CarouselTokens } from '../Carousel/tokens';
+import type {
+ CarouselItemMask,
+ CarouselLayout,
+ CarouselRenderItemInfo,
+} from '../Carousel/types';
+import {
+ getSnapScrollProps,
+ resolveItemPlacement,
+ resolveSettleOffset,
+} from '../Carousel/utils';
+
+const CONTAINER = 360;
+const HEIGHT = 200;
+
+const strategyFor = (
+ layout: CarouselLayout,
+ overrides: Partial[0]> = {}
+): CarouselStrategy => {
+ const strategy = createStrategy({
+ layout,
+ alignment: 'start',
+ containerSize: CONTAINER,
+ containerHeight: HEIGHT,
+ preferredItemSize: 220,
+ itemCount: 10,
+ ...overrides,
+ });
+ if (!strategy) {
+ throw new Error(`no strategy for ${layout}`);
+ }
+ return strategy;
+};
+
+const inContainerSizes = (strategy: CarouselStrategy) =>
+ // Drop the gone and anchor keylines at either end.
+ strategy.defaultState.sizes.slice(2, -2);
+
+describe('createStrategy', () => {
+ it('returns null until the container has been measured', () => {
+ const options = {
+ layout: 'multi-browse' as const,
+ alignment: 'start' as const,
+ containerHeight: HEIGHT,
+ preferredItemSize: 220,
+ };
+ expect(
+ createStrategy({ ...options, containerSize: 0, itemCount: 10 })
+ ).toBeNull();
+ expect(
+ createStrategy({ ...options, containerSize: CONTAINER, itemCount: 0 })
+ ).toBeNull();
+ });
+
+ it.each([
+ 'multi-browse',
+ 'hero',
+ 'uncontained',
+ 'full-screen',
+ ])('fills the container exactly for %s', (layout) => {
+ const sizes = inContainerSizes(strategyFor(layout));
+ const total = sizes.reduce((sum, size) => sum + size, 0);
+ expect(total).toBeCloseTo(CONTAINER, 5);
+ });
+
+ it('keeps small items inside the 40–56dp band', () => {
+ const strategy = strategyFor('multi-browse');
+ const sizes = inContainerSizes(strategy);
+ const smallest = sizes[sizes.length - 1];
+ expect(smallest).toBeGreaterThanOrEqual(CarouselGeometry.smallSizeMin);
+ expect(smallest).toBeLessThanOrEqual(CarouselGeometry.smallSizeMax);
+ });
+
+ it('sizes the multi-browse medium item as the mean of large and small', () => {
+ const sizes = inContainerSizes(strategyFor('multi-browse'));
+ const [large, medium, small] = sizes;
+ expect(sizes).toHaveLength(3);
+ expect(medium).toBeCloseTo((large + small) / 2, 5);
+ });
+
+ it('lands multi-browse large items near the requested size', () => {
+ const strategy = strategyFor('multi-browse', { preferredItemSize: 220 });
+ expect(strategy.itemSize).toBeGreaterThan(180);
+ expect(strategy.itemSize).toBeLessThan(260);
+ });
+
+ it('gives hero a single focal item with a peek beside it', () => {
+ const strategy = strategyFor('hero');
+ expect(strategy.focalCount).toBe(1);
+ expect(inContainerSizes(strategy)).toHaveLength(2);
+ });
+
+ it('keeps a hero large item within twice its own height', () => {
+ const strategy = strategyFor('hero');
+ expect(strategy.itemSize).toBeLessThanOrEqual(
+ CarouselGeometry.heroLargeMaxAspectRatio * HEIGHT
+ );
+ });
+
+ it('centre-aligns hero with a peek on either side', () => {
+ const sizes = inContainerSizes(
+ strategyFor('hero', { alignment: 'center', itemCount: 5 })
+ );
+ expect(sizes).toHaveLength(3);
+ expect(sizes[0]).toBeCloseTo(sizes[2], 5);
+ expect(sizes[1]).toBeGreaterThan(sizes[0]);
+ });
+
+ it('falls back to start-aligned hero below three items', () => {
+ const sizes = inContainerSizes(
+ strategyFor('hero', { alignment: 'center', itemCount: 2 })
+ );
+ expect(sizes).toHaveLength(2);
+ expect(sizes[0]).toBeGreaterThan(sizes[1]);
+ });
+
+ it('hands uncontained items the width they asked for', () => {
+ const strategy = strategyFor('uncontained', { preferredItemSize: 160 });
+ expect(strategy.itemSize).toBeCloseTo(160, 5);
+ });
+
+ it('caps an uncontained cut-off item below the medium threshold', () => {
+ // A 190dp item in a 360dp container leaves a 170dp strip — 89% of a full
+ // item, close enough that it would stop reading as a peek.
+ const strategy = strategyFor('uncontained', { preferredItemSize: 190 });
+ const sizes = inContainerSizes(strategy);
+ expect(sizes).toHaveLength(2);
+ expect(sizes[1] / sizes[0]).toBeLessThanOrEqual(
+ CarouselGeometry.uncontainedMediumThreshold + 1e-6
+ );
+ });
+
+ it('gives full-screen one item filling the container', () => {
+ const strategy = strategyFor('full-screen');
+ expect(strategy.itemSize).toBe(CONTAINER);
+ expect(strategy.focalCount).toBe(1);
+ });
+
+ it('picks a distinct fling behaviour per layout', () => {
+ expect(strategyFor('multi-browse').snap).toBe('multi');
+ expect(strategyFor('hero').snap).toBe('single');
+ expect(strategyFor('full-screen').snap).toBe('single');
+ expect(strategyFor('uncontained').snap).toBe('none');
+ });
+
+ it('honours an explicit snap override', () => {
+ expect(strategyFor('uncontained', { snap: 'single' }).snap).toBe('single');
+ });
+
+ it('leaves room for the focal items at the end of the scroll', () => {
+ const strategy = strategyFor('multi-browse', { itemCount: 10 });
+ expect(strategy.maxScroll).toBeCloseTo(
+ (10 - strategy.focalCount) * strategy.itemSize,
+ 5
+ );
+ expect(strategy.contentSize).toBeCloseTo(strategy.maxScroll + CONTAINER, 5);
+ });
+
+ it('does not scroll when every item is already focal', () => {
+ expect(strategyFor('full-screen', { itemCount: 1 }).maxScroll).toBe(0);
+ });
+});
+
+describe('resolveItemPlacement', () => {
+ it('puts the first item on the focal keyline at rest', () => {
+ const strategy = strategyFor('multi-browse');
+ const placement = resolveItemPlacement(strategy, 0, 0);
+ expect(placement.size).toBeCloseTo(strategy.itemSize, 5);
+ expect(placement.center).toBeCloseTo(strategy.itemSize / 2, 5);
+ });
+
+ it('puts the last item on the focal keyline at the end of the scroll', () => {
+ const strategy = strategyFor('multi-browse', { itemCount: 10 });
+ const placement = resolveItemPlacement(strategy, strategy.maxScroll, 9);
+ expect(placement.size).toBeCloseTo(strategy.itemSize, 5);
+ });
+
+ it('snaps on whole items, so every snap target is a focal keyline', () => {
+ const strategy = strategyFor('multi-browse', { itemCount: 10 });
+ for (let index = 0; index < 8; index++) {
+ const placement = resolveItemPlacement(
+ strategy,
+ index * strategy.itemSize,
+ index
+ );
+ expect(placement.size).toBeCloseTo(strategy.itemSize, 5);
+ }
+ });
+
+ it('shrinks an item monotonically as it leaves the focal range', () => {
+ const strategy = strategyFor('multi-browse', { itemCount: 10 });
+ const sizes = [0, 0.25, 0.5, 0.75, 1].map(
+ (step) =>
+ resolveItemPlacement(strategy, (3 + step) * strategy.itemSize, 3).size
+ );
+ for (let i = 1; i < sizes.length; i++) {
+ expect(sizes[i]).toBeLessThanOrEqual(sizes[i - 1] + 1e-6);
+ }
+ expect(sizes[sizes.length - 1]).toBeLessThan(sizes[0]);
+ });
+
+ it('collapses an off-screen item to the gone keyline', () => {
+ const strategy = strategyFor('multi-browse', { itemCount: 20 });
+ const placement = resolveItemPlacement(strategy, 10 * strategy.itemSize, 0);
+ expect(placement.size).toBeCloseTo(CarouselGeometry.goneSize, 5);
+ });
+
+ it('stays continuous across the start and end shift boundaries', () => {
+ const strategy = strategyFor('hero', {
+ alignment: 'center',
+ itemCount: 10,
+ });
+ const epsilon = 0.001;
+ for (const boundary of [
+ strategy.startShift,
+ strategy.maxScroll - strategy.endShift,
+ ]) {
+ for (let index = 0; index < 4; index++) {
+ const before = resolveItemPlacement(
+ strategy,
+ boundary - epsilon,
+ index
+ );
+ const after = resolveItemPlacement(strategy, boundary + epsilon, index);
+ expect(after.size).toBeCloseTo(before.size, 2);
+ expect(after.center).toBeCloseTo(before.center, 2);
+ }
+ }
+ });
+
+ it('never resizes the item box, only the mask', () => {
+ const strategy = strategyFor('multi-browse', { itemCount: 10 });
+ const sizes = new Set();
+ for (let scroll = 0; scroll < strategy.maxScroll; scroll += 17) {
+ sizes.add(resolveItemPlacement(strategy, scroll, 4).size);
+ }
+ // The mask varies while the box the item is laid out at does not.
+ expect(sizes.size).toBeGreaterThan(1);
+ expect(strategy.itemSize).toBeCloseTo(inContainerSizes(strategy)[0], 5);
+ });
+});
+
+describe('Carousel', () => {
+ const data = ['a', 'b', 'c', 'd', 'e', 'f'];
+
+ const layout = (width: number) => ({
+ nativeEvent: { layout: { width, height: HEIGHT, x: 0, y: 0 } },
+ });
+
+ const renderCarousel = async (props = {}) => {
+ await render(
+ (
+
+
+ {item}
+
+
+ )}
+ {...props}
+ />
+ );
+ };
+
+ const measure = async (width = CONTAINER) => {
+ await fireEvent(screen.getByTestId('carousel'), 'layout', layout(width));
+ };
+
+ it('renders no items until it has been measured', async () => {
+ await renderCarousel();
+ // The scroll view is always present; the arrangement needs a width first.
+ expect(screen.getByTestId('carousel')).toBeTruthy();
+ expect(screen.queryByText('a')).toBeNull();
+ });
+
+ it('renders items once measured', async () => {
+ await renderCarousel();
+ await measure();
+ expect(screen.getByTestId('carousel')).toBeTruthy();
+ expect(screen.getByText('a')).toBeTruthy();
+ });
+
+ it('hands renderItem a live mask rectangle', async () => {
+ let captured: CarouselItemMask | undefined;
+ await render(
+ ) => {
+ captured ??= mask;
+ return ;
+ }}
+ />
+ );
+ await measure();
+
+ expect(captured?.height).toBe(HEIGHT);
+ expect(captured?.width).toBeGreaterThan(0);
+ expect(captured?.rect.value).toEqual(
+ expect.objectContaining({ top: 0, bottom: HEIGHT })
+ );
+ // At rest the first item is focal, so its mask covers the whole item.
+ expect(captured?.rect.value.left).toBeCloseTo(0, 5);
+ expect(captured?.expansion.value).toBeCloseTo(1, 5);
+ });
+
+ it('does not make items interactive without onItemPress', async () => {
+ await renderCarousel();
+ await measure();
+ expect(screen.queryByRole('button')).toBeNull();
+ });
+
+ it('calls onItemPress when an item is pressed', async () => {
+ const onItemPress = jest.fn();
+ await renderCarousel({ onItemPress });
+ await measure();
+
+ const user = userEvent.setup();
+ const items = screen.getAllByRole('button');
+ expect(items.length).toBeGreaterThan(0);
+ await user.press(items[0]);
+ expect(onItemPress).toHaveBeenCalledWith('a', 0);
+ });
+
+ it('does not respond to presses when disabled', async () => {
+ const onItemPress = jest.fn();
+ await renderCarousel({ onItemPress, disabled: true });
+ await measure();
+ expect(screen.queryByRole('button')).toBeNull();
+ expect(onItemPress).not.toHaveBeenCalled();
+ });
+
+ it('only mounts the items that can be on screen', async () => {
+ await renderCarousel({
+ data: Array.from({ length: 50 }, (_, i) => `${i}`),
+ });
+ await measure();
+ expect(screen.getByText('0')).toBeTruthy();
+ expect(screen.queryByText('49')).toBeNull();
+ });
+
+ it('reports the focal index once scrolling settles', async () => {
+ const onIndexChange = jest.fn();
+ await renderCarousel({ onIndexChange });
+ await measure();
+
+ const { itemSize } = strategyFor('multi-browse', {
+ itemCount: data.length,
+ });
+ await fireEvent(screen.getByTestId('carousel'), 'momentumScrollEnd', {
+ nativeEvent: { contentOffset: { x: itemSize * 2, y: 0 } },
+ });
+ expect(onIndexChange).toHaveBeenCalledWith(2);
+ });
+});
+
+describe('getSnapScrollProps', () => {
+ it('switches native deceleration off wherever the carousel snaps', () => {
+ for (const layout of ['hero', 'full-screen', 'multi-browse'] as const) {
+ expect(getSnapScrollProps(strategyFor(layout)).decelerationRate).toBe(0);
+ }
+ });
+
+ it('leaves native momentum alone for uncontained', () => {
+ expect(
+ getSnapScrollProps(strategyFor('uncontained')).decelerationRate
+ ).toBe('normal');
+ });
+});
+
+describe('resolveSettleOffset', () => {
+ const at = (strategy: CarouselStrategy, index: number) =>
+ index * strategy.itemSize;
+
+ it('does not settle an uncontained carousel at all', () => {
+ const strategy = strategyFor('uncontained');
+ expect(
+ resolveSettleOffset(strategy, at(strategy, 2) + 30, at(strategy, 2), 2000)
+ ).toBeNull();
+ });
+
+ it('carries a multi-browse fling across several items', () => {
+ const strategy = strategyFor('multi-browse', { itemCount: 20 });
+ // Half a second of travel at four items per second is two items.
+ const target = resolveSettleOffset(strategy, 0, 0, strategy.itemSize * 4);
+ expect(target).toBeCloseTo(at(strategy, 2), 5);
+ });
+
+ it('advances multi-browse backwards on a reverse fling', () => {
+ const strategy = strategyFor('multi-browse', { itemCount: 20 });
+ const from = at(strategy, 6);
+ const target = resolveSettleOffset(
+ strategy,
+ from,
+ from,
+ -strategy.itemSize * 4
+ );
+ expect(target).toBeCloseTo(at(strategy, 4), 5);
+ });
+
+ it('advances hero by exactly one item however hard it is flung', () => {
+ const strategy = strategyFor('hero', { itemCount: 20 });
+ const from = at(strategy, 3);
+ for (const velocity of [strategy.itemSize, strategy.itemSize * 50]) {
+ expect(resolveSettleOffset(strategy, from, from, velocity)).toBeCloseTo(
+ at(strategy, 4),
+ 5
+ );
+ }
+ expect(
+ resolveSettleOffset(strategy, from, from, -strategy.itemSize * 50)
+ ).toBeCloseTo(at(strategy, 2), 5);
+ });
+
+ it('returns a held hero item to where the drag started', () => {
+ const strategy = strategyFor('hero', { itemCount: 20 });
+ const from = at(strategy, 3);
+ const target = resolveSettleOffset(strategy, from + 4, from, 0);
+ expect(target).toBeCloseTo(from, 5);
+ });
+
+ it('settles on focal keyline offsets, never between them', () => {
+ const strategy = strategyFor('multi-browse', { itemCount: 20 });
+ for (let velocity = -4000; velocity <= 4000; velocity += 250) {
+ const target = resolveSettleOffset(
+ strategy,
+ at(strategy, 5),
+ at(strategy, 5),
+ velocity
+ );
+ if (target === null) {
+ throw new Error('a snapping carousel must settle somewhere');
+ }
+ const index = target / strategy.itemSize;
+ expect(Math.abs(index - Math.round(index))).toBeLessThan(1e-9);
+ }
+ });
+
+ it('never settles past either end of the list', () => {
+ const strategy = strategyFor('multi-browse', { itemCount: 8 });
+ expect(resolveSettleOffset(strategy, 0, 0, -50000)).toBe(0);
+ expect(resolveSettleOffset(strategy, strategy.maxScroll, 0, 50000)).toBe(
+ strategy.maxScroll
+ );
+ });
+});
+
+describe('CarouselTokens', () => {
+ it('takes its corner radius from the shape scale', () => {
+ expect(CarouselTokens.containerShape).toBe('extraLarge');
+ });
+
+ it('is the only non-zero elevation in the set', () => {
+ expect(CarouselTokens.hoverContainerElevation).toBe(1);
+ expect(CarouselTokens.containerElevation).toBe(0);
+ expect(CarouselTokens.focusContainerElevation).toBe(0);
+ expect(CarouselTokens.pressedContainerElevation).toBe(0);
+ });
+
+ it('disables at the spec opacity', () => {
+ expect(CarouselTokens.disabledContainerOpacity).toBe(0.38);
+ });
+
+ it('declares no token the component does not consume', () => {
+ const source = [
+ readFileSync(
+ join(__dirname, '../Carousel/CarouselItemShell.tsx'),
+ 'utf8'
+ ),
+ readFileSync(join(__dirname, '../Carousel/utils.ts'), 'utf8'),
+ ].join('\n');
+
+ for (const name of Object.keys(CarouselTokens)) {
+ expect(source).toContain(name);
+ }
+ });
+});
diff --git a/src/index.tsx b/src/index.tsx
index f46d8e22d8..7884806d34 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -27,6 +27,11 @@ export { default as Banner } from './components/Banner';
export { default as BottomNavigation } from './components/BottomNavigation/BottomNavigation';
export { default as Button } from './components/Button/Button';
export { default as Card } from './components/Card/Card';
+export {
+ default as Carousel,
+ CarouselItem,
+ CarouselItemContent,
+} from './components/Carousel';
export { default as Checkbox } from './components/Checkbox';
export { default as Chip } from './components/Chip/Chip';
export { default as DataTable } from './components/DataTable/DataTable';
@@ -75,6 +80,17 @@ export type { Props as CardActionsProps } from './components/Card/CardActions';
export type { Props as CardContentProps } from './components/Card/CardContent';
export type { Props as CardCoverProps } from './components/Card/CardCover';
export type { Props as CardTitleProps } from './components/Card/CardTitle';
+export type {
+ CarouselAlignment,
+ CarouselHandle,
+ CarouselItemMask,
+ CarouselItemProps,
+ CarouselLayout,
+ CarouselMaskRect,
+ CarouselRenderItemInfo,
+ CarouselSnap,
+ Props as CarouselProps,
+} from './components/Carousel';
export type { Props as CheckboxProps } from './components/Checkbox/Checkbox';
export type { Props as CheckboxItemProps } from './components/Checkbox/CheckboxItem';
export type { Props as ChipProps } from './components/Chip/Chip';