Skip to content

feat: add Carousel component - #5123

Open
andriicallstack wants to merge 4 commits into
callstack:mainfrom
andriicallstack:feat/carousel
Open

andriicallstack wants to merge 4 commits into
callstack:mainfrom
andriicallstack:feat/carousel

Conversation

@andriicallstack

@andriicallstack andriicallstack commented Sep 15, 2026 •

Copy link
Copy Markdown

Motivation

react-native-paper has no Carousel. It is one of the Material 3 Expressive components that the v6 modernization effort does not have yet. This PR adds it, with the four layouts of the spec: multi-browse, hero, uncontained and full-screen.

Two facts about the component are not obvious. They shape the whole implementation:

The component never resizes an item.

  • Each item has the large size for all of its life.
  • The visible change of size comes from an interpolated mask rectangle and a translation.
  • The items are stacked in descending order. Thus a collapsing item moves under the item before it.
  • The corner radius is on the mask, not on the item box, because it must move with the visible rectangle.
  • Thus photos never reflow or squash when they get smaller.

Only two layouts use the arrangement solver.

  • Uncontained does not use the solver and does not snap. MDC recommends it when the item aspect ratios must stay the same, which is the usual React Native case.
  • Full-screen shows one item.
  • Multi-browse and hero use the cost-minimising arrangement.

Related issue

None yet. I will create one.

Implementation notes

Read these notes before the diff. The layout model is most of the change.

Keylines.

  • Items are on an unmasked axis. Item i has its centre at (i + 0.5) × itemSize.
  • A keyline list maps a position on that axis to a drawn centre and a masked size. Thus one interpolation gives both the translation and the mask.
  • The axis is uniform, so each snap target is a whole multiple of the item size. Thus the snap target is the focal keyline offset, not the container edge.
  • Near each end, the keylines blend into two permuted states. These states put the focal range at the start or at the end of the container. Thus the first item and the last item can become focal.
  • The three states are permutations of the same sizes. They always have the same number of keylines and interpolate in pairs.
  • The focal index blends with them. Thus the mapping stays continuous across the boundary. A test checks exactly this.

Geometry. The token table has no geometry, so the values come from the MDC-Android and Compose implementations:

  • small item: minimum 40dp, maximum 56dp;
  • anchor: 10dp; gone: 1dp;
  • targetSmall = clamp(large / 3, 40, 56);
  • targetMedium = (large + small) / 2;
  • hero large: at most 2× its height;
  • uncontained medium threshold: 0.85;
  • arrangement cost: |targetLarge − large| × priority.

Motion. The motion uses two tiers on purpose.

  • The mask is a pure function of the scroll offset. The component reads it directly from a shared value. A spring on the mask would make it lag behind the finger.
  • The spring is for the settle:
    1. Native deceleration is off where the carousel snaps, so the scroll view stops when the finger lifts.
    2. A shared value with withSpring(toRawSpring(theme.motion.spring.default.spatial)) moves the scroll position from the UI thread.
    3. A new touch cancels the spring. Thus the settle is interruptible.
  • Programmatic scrollToIndex uses the same spring. The spring obeys reduce-motion.
  • State layers, the focus ring, disabled opacity and the hover elevation do not depend on the scroll. They run as Reanimated CSS transitions.
  • The component calculates the release velocity from successive scroll offsets. It does not read the velocity from the drag-end event, because the units and the sign of the event are different on each platform. The offsets are the same.

Three fling behaviours. All three are in resolveSettleOffset, a pure worklet. Thus unit tests check them directly, not through scroll view props:

Layout Settle
multi-browse the decay projection moves across as many items as the fling gives, then springs
hero, full-screen single advance: at most one item from the start of the drag, for each fling force
uncontained no snap

Content-vs-mask API. The spec tells item content to react to the mask. The two reference implementations make this public: OnMaskChangedListener on Android and carouselItemDrawInfo.maskRect in Compose. renderItem gets the live mask as shared values:

<Carousel
  data={photos}
  height={200}
  itemWidth={220}
  renderItem={({ item, mask }) => (
    <CarouselItem mask={mask}>
      <Image source={item.source} style={{ width: '100%', height: '100%' }} />
      <CarouselItemContent mask={mask}>
        <Text variant="labelLarge">{item.title}</Text>
      </CarouselItemContent>
    </CarouselItem>
  )}
/>
  • CarouselItem is the full-size box for the media.
  • CarouselItemContent stays at the leading edge of the mask and fades as the item collapses. The spec asks this behaviour for the text of an item.
  • A read of the mask in useAnimatedStyle causes no re-render.

Tokens.

  • md.comp.carousel-item.* is the whole spec surface of the component. There is no md.comp.carousel.*, no plural and no .expressive. variant. Thus baseline and Expressive have the same values.
  • tokens.ts holds the component tokens.
  • container.shape uses corner.extraLarge. State layers use md.sys.state. The focus indicator uses the system thickness and offset.

The component does not mirror two token groups on purpose. A test fails the build if an unused token comes back:

  • label-text.*: MDC draws the label of an item itself. In Paper, the caller renders the item content, text included, so a label colour has nothing to paint. The disabled state dims all of the content.
  • dragged.*: a drag on an item scrolls the strip. React Native gives the responder to the scroll view, and this ends the press of the item. Thus no interaction remains for a dragged state.

Test plan

  1. Run yarn example ios (or android).
  2. Open Carousel from the example list.

The screen has each layout, toggles for the outlined and disabled variants, and Previous / Next buttons. The buttons move the carousel through its ref.

Check these by hand:

  • Fling multi-browse hard. The momentum must move across several items and then spring into place.
  • Fling hero hard. It must move exactly one item, for each fling force.
  • Uncontained must not snap.
  • Touch the strip during a settle. The spring must stop at once and follow your finger.
  • Watch a photo get smaller. It must crop, never squash. Its label must move with the leading edge of the mask and fade out.
  • Turn on Reduce Motion. The settle must jump, not spring.

Automated checks:

  • 47 tests cover:
    • the arrangement solver;
    • the keyline placement: continuity across the shift boundaries, monotone shrink, exact container fill, and focal alignment at the two scroll ends;
    • the three settle behaviours;
    • the mask that renderItem gets;
    • the token table.
  • yarn lint, yarn typecheck and yarn test are clean.
  • The docs site builds and makes the three component pages.

Screenshots

Multi-browse, during a scroll: one large item, one medium item and one small item. The leading item collapses to a sliver. The mask crops each photo. The component does not resize it:

Multi-browse Hero, start-aligned
Hero, centre-aligned Uncontained
Full-screen Outlined

The solver selects more large items when the container gets wider:

The settle spring, moved through the ref of the component. It goes a little past the focal keyline, then stops on it:

Known gaps

  • The touch-drag settle did not run on a device yet.
    • The spring, the UI-thread scrollTo and the velocity worklet ran on react-native-web.
    • Unit tests cover the settle maths, which does not depend on the platform.
    • The handoff from a native drag to the spring (onScrollBeginDrag / onScrollEndDrag with decelerationRate={0}) runs only on iOS and Android. It needs a check on the two platforms before merge.
  • RTL is not handled for the scroll direction. The layout is left-to-right. The component docs tell this.
  • The token values need a check against the latest published spec table.
  • The docs have no screenshots yet. docs/public/screenshots/ holds real device captures for each platform. A native build must make them.

Adds the Material 3 Expressive Carousel with all four layouts —
multi-browse, hero (start- and centre-aligned), uncontained and
full-screen.

Items are never resized. Every item is measured at the large size for
the whole of its life; the apparent size change is an interpolated mask
rectangle plus a translation, with items stacked in descending order so
a collapsing item passes under the one before it. The corner radius sits
on the mask rather than on the item box, so it travels with the visible
rectangle.

Layout runs on an unmasked axis where every item occupies one item size,
and a keyline list maps a position on that axis onto a drawn centre and
a masked size. Near either end the keylines blend into permuted states
that put the focal range hard against the start or the end, so the first
and last items can be focal. Multi-browse and hero resolve their
arrangements through a cost-minimising solver; uncontained and
full-screen need none.

Each layout gets its own fling behaviour: single-advance for hero and
full-screen, decay-plus-spring across several items for multi-browse,
and no snap at all for uncontained. Where it snaps, the target is the
focal keyline offset — a whole number of items on the unmasked axis —
not the container edge.

renderItem receives the item's live mask as shared values, the
equivalent of OnMaskChangedListener and carouselItemDrawInfo.maskRect in
the reference implementations. CarouselItemContent pins to the mask's
leading edge and fades as the item collapses.

Scroll-driven mask interpolation runs on the UI thread through
Reanimated shared values; the state layer, focus ring, disabled opacity
and hover elevation are not scroll-driven and run as CSS transitions.
Seven of the tokens mirrored from `md.comp.carousel-item.*` had no
consumer, which read as if the component themed things it does not.

The `label-text.*` group goes: MDC draws an item's label itself, but in
Paper the item's content — text included — is rendered by the caller, so
there is nothing for a label colour to paint, and disabled dims the
content as a whole rather than per-run. The `dragged.*` group goes too: a
drag on an item scrolls the strip, and React Native hands the responder
to the scroll view, which ends the item's press, so no dragged state is
reachable.

The focus and pressed elevations stay and are now read: elevation
resolves from the interaction state rather than testing hover alone, so
the fact that hover is the only state that lifts an item is expressed in
the tokens instead of in a branch.

A test walks the token table and fails on any entry the component never
reads, so this cannot drift back.
The settle was the scroll view's own deceleration curve: `snapToInterval`
with `disableIntervalMomentum` for single-advance and plain momentum for
multi-item. That behaved roughly right but was the platform's motion, not
the spec's, and it left the carousel with no imperative tier at all.

Native deceleration is now switched off wherever the carousel snaps, so
the scroll view stops when the finger lifts and we own the settle: a
shared value sprung with `withSpring(toRawSpring(motion.spring.default
.spatial))`, written into the scroll view from the UI thread. A new touch
cancels it, which is what makes the settle interruptible. Programmatic
`scrollToIndex` moves settle on the same spring, and the spring honours
reduce-motion. An uncontained carousel does not snap, so it keeps native
momentum.

Release velocity is measured from successive scroll offsets rather than
read from the drag-end event: the event's units and sign vary between
platforms, whereas the offsets do not.

The decay projection and the snap target move into `resolveSettleOffset`,
a pure worklet, so the three fling behaviours are unit-tested directly
instead of inferred from scroll view props — single-advance clamps to one
item from where the drag began however hard it is thrown, multi rides the
projection across as many items as the fling earned, and both land on a
focal keyline and never past either end.

The example grows Previous / Next buttons that drive the carousel through
its ref, showing the programmatic path uses the same spring.
`main` removed derived testIDs across the library, so the carousel should
not arrive with new ones. `${testID}-scroll-view` and `${testID}-item-N`
are gone; `testID` now lands on the scroll view, which is the element a
caller actually addresses.

That meant the scroll view could no longer be conditional on having been
measured, since it carries the testID and the layout callback. It now
always renders and the items wait for the arrangement instead, which also
removes a layout jump on first paint.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Blocking edge-case hangs, layout-contract violations, callback inconsistencies, accessibility gaps, and native validation concerns remain.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds a Material 3 Carousel component with four layouts, animated masking, snapping, theming, documentation, and examples.

Changes:

  • Implements carousel geometry, rendering, interaction, and motion.
  • Exposes public components, handles, types, and theme tokens.
  • Adds tests, documentation configuration, and an example screen.
File summaries
File Description
src/index.tsx Exports the Carousel API.
src/components/Carousel/utils.ts Adds placement and settling utilities.
src/components/Carousel/types.ts Defines public Carousel types.
src/components/Carousel/tokens.ts Defines visual and geometry tokens.
src/components/Carousel/strategy.ts Implements layout strategies and keylines.
src/components/Carousel/index.ts Defines module exports.
src/components/Carousel/CarouselItemShell.tsx Renders masked, interactive items.
src/components/Carousel/CarouselItem.tsx Adds item-content helpers.
src/components/Carousel/Carousel.tsx Implements scrolling, settling, and virtualization.
src/components/__tests__/Carousel.test.tsx Tests layouts, placement, interaction, and tokens.
example/src/Examples/CarouselExample.tsx Demonstrates all layouts and states.
example/src/ExampleList.tsx Registers the example.
docs/src/data/themeColors.ts Documents theme color mappings.
docs/component-docs.config.ts Registers generated component documentation.
Review details

Suppressed comments (6)

src/components/Carousel/strategy.ts:396

  • When there are fewer items than fit, this branch expands them to fill the container. A single requested 160dp item in a 360dp container becomes 360dp, contradicting the public promise that uncontained items retain their requested width/aspect ratio. Return the requested-size items and leave the unused trailing space instead.
  if (mediumSize <= 0 || itemCount <= largeCount) {
    // No cut-off item, so the large items have to fill the container on their own.
    largeSize = available / largeCount;

src/components/Carousel/Carousel.tsx:293

  • Ignoring a stale sample without clearing velocity preserves the last fast movement. If the user drags quickly, pauses, then releases, onEndDrag can project that old velocity and fling even though release velocity is zero. Reset velocity after stale gaps and also reject a velocity whose final sample is stale at release.
        // 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;

src/components/Carousel/Carousel.tsx:352

  • The non-animated path reports the requested clamped index rather than the index represented by the clamped target. Near the end, target may be maxScroll; with multiple focal items, requesting the last item therefore reports a different index than the animated path for the same final offset. Report Math.round(target / strategy.itemSize) so callback behavior is animation-independent.
      } else {
        scrollRef.current?.scrollTo({ x: target, animated: false });
        reportIndex(clamped);

src/components/Carousel/Carousel.tsx:329

  • Despite the preceding comment, this reports momentum-end events for snapping layouts too. If native momentum ends at the release offset before the custom spring reaches its target, consumers receive an intermediate index and then another index from the spring completion, violating “once scrolling settles.” Gate this handler to strategy.snap === 'none' and update the component test to exercise the appropriate layout.
    if (itemSize > 0) {
      reportIndex(Math.round(event.nativeEvent.contentOffset.x / itemSize));
    }

src/components/Carousel/Carousel.tsx:307

  • The new native-critical settle path has no test that drives begin-drag/end-drag, verifies spring handoff, or interrupts an in-flight settle; the current tests only exercise the pure target resolver, and the PR notes that this path has not run on iOS or Android. Add handler-level coverage and complete native validation before merging.
      onEndDrag: (event) => {
        if (!strategy) return;

src/components/Carousel/strategy.ts:353

  • The two-item cap can defeat the advertised hero aspect-ratio cap. For example, a 1200×200 start-aligned carousel computes (1200 - 56) / 2 = 572, exceeding maxLargeWidth (400). The wide-container fallback needs to preserve the 2×height limit rather than always redistributing all remaining width across at most two focal items.
    Math.ceil(spaceForLarge / Math.max(maxLargeWidth, 1)),
    1,
    Math.min(HERO_LARGE_COUNT_MAX, Math.max(itemCount - smallCount, 1))
  );
  const largeSize = spaceForLarge / largeCount;
  • Files reviewed: 16/16 changed files
  • Comments generated: 4
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +299 to +302
const largeCounts = descendingRange(
Math.max(1, Math.floor(available / targetLarge)),
1
);
const lastTimestamp = useSharedValue(0);

const [containerWidth, setContainerWidth] = React.useState(0);
const [windowStart, setWindowStart] = React.useState(initialIndex);
Comment on lines +71 to +72
const { itemSize } = strategy;
const contentWidth = Math.max(itemSize - spacing, 0);
Comment on lines +205 to +225
{content}
{interactive ? (
<Pressable
style={[
StyleSheet.absoluteFill,
Platform.OS === 'web' ? webNoOutline : undefined,
]}
onPress={() => 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}
Comment thread node_modules

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.

Leftover from development

@satya164

satya164 commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Review by Claude

iOS

Multi-browse rests with no large item on the second-to-last index

  1. Open the Carousel example.
  2. Fling the multi-browse carousel to the end, or press Next until "Focused item" is Chameleon (index 4 of 6). Press Previous from Strawberries gives the same result.

What happens: the carousel settles with three items of about the same medium width. None of them is large, and all three labels stay half faded. "Focused item" says Chameleon, but no item is focal.

What should happen: every resting position shows a large focal item, like index 3 and index 5 do.

iOS, multi-browse at index 4: no large item, all labels faded

The cause is in the keyline states, see the inline comment on strategy.ts. (via Claude, verify if it makes sense)

Items jump out of place for a few frames during a fling

  1. Fling the multi-browse carousel hard from the start towards the end.
  2. Record the screen (xcrun simctl io <udid> recordVideo) and step through the frames.

What happens: for 2 to 3 frames in a row, the items shift sideways and leave a large empty gap at one edge of the carousel. These are three consecutive frames from one fling:

iOS, three consecutive frames during a fling: items shifted, empty space on the right

What should happen: the items always fill the container edge to edge while they move.

More detail in the inline comment on CarouselItemShell.tsx. (via Claude, verify if it makes sense)

Android

Same frame skipping during a fling

  1. Fling the multi-browse carousel from Beach.
  2. Record the screen (adb shell screenrecord) and step through the frames.

What happens: the same sideways jump with an empty gap on the right, here on the frames just before it settles on Forest:

Android, consecutive frames during a fling: the last frame has an empty gap on the right

What should happen: the items always fill the container. (via Claude, verify if it makes sense)

@satya164 satya164 left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review by Claude

Device results for points already raised:

  • The onMomentumScrollEnd point from the Copilot review: during one fling settle on iOS, "Focused item" changed to City, then Chameleon, then Strawberries.
  • The unnamed item button: the iOS accessibility tree lists the item buttons with no label.
  • itemSpacing shrinking itemWidth: uncontained with itemWidth={260} draws 252 pt items. (via Claude, verify if it makes sense)

Comment on lines +116 to +128
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
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Each start and end state is a full permutation, and resolveItemPlacement blends from the default state straight into it over endShift. For multi-browse, endShift is 2 items ([L, M, S] to [S, M, L]), so the halfway point is a whole item offset and a valid snap target. At that offset every keyline is a blend of a large and a small size, so no item is large. On device the carousel rests at index n - 2 with three medium items and all labels half faded, and onIndexChange reports an item that is not focal:

iOS, multi-browse at index 4: no large item, all labels faded

Build one intermediate state per item of shift, moving one keyline at a time (like the start and end steps in MDC's KeylineStateList), and interpolate between neighboring steps. Then every whole item offset is a valid arrangement with a large item. (via Claude, verify if it makes sense)


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++) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This loop stops at index 7, which is where the end shift starts (maxScroll is 9 items and endShift is 2). Index 8 is the snap target that breaks. With index < 10 this test fails with "Expected: 200, Received: 120". Cover every index up to itemCount - 1:

Suggested change
for (let index = 0; index < 8; index++) {
for (let index = 0; index < 10; index++) {

(via Claude, verify if it makes sense)

Comment on lines +96 to +105
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]
);

@satya164 satya164 Oct 22, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Items cancel the native scroll by adding scrollX.value back into their transform. Reanimated pauses its commits while a React commit is in progress, so during any re-render the item transforms keep an old scrollX while the native content keeps moving, and the items jump sideways (details in the reply below). A screen recording of a fling shows 2 to 3 frames in a row with a large empty gap, on iOS and on Android:

iOS, three consecutive frames during a fling

Android, consecutive frames during a fling

One fix is to stop moving the content natively. Drive one offset shared value from a Gesture Handler pan (with withDecay or withSpring for the settle) and derive every item transform from it, so the offset and the transforms always change in the same frame. That also removes the dependency on decelerationRate={0}. (via Claude, verify if it makes sense)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The cause is React commits during the fling. Reanimated pauses its own UI-thread commits while a React commit is in progress (pauseReanimatedCommits in ReanimatedCommitHook.cpp, released in ReanimatedMountHook.cpp). For those frames the item transforms keep their old scrollX, while the native content offset keeps moving, so the items slide sideways and leave the gap. The onScroll worklet itself runs in the same frame as the scroll.

Two things in the PR commit during a fling: setWindowStart from the render window reaction (each time an item boundary passes), and onIndexChange, which the example uses to re-render the whole screen and which fires several times per settle. I counted frames with a gap wider than 70 pt over 6 flings on the iOS simulator:

  • PR as is: 11 and 8 frames in two runs (about 40 React commits, 19 of them longer than 30 ms)
  • render window updates only: 4 frames
  • onIndexChange only: 11 frames
  • neither (all items mounted, no onIndexChange): 1 and 0 frames

So any re-render on the screen while the carousel moves causes the jump, also re-renders that the app does. Driving one shared offset from a Gesture Handler pan, as suggested above, keeps the offset and the transforms in the same commit. Cutting the re-renders reduces the jumps but does not remove them for the app's own renders. (via Claude, verify if it makes sense)

}}
onBlur={() => setFocused(false)}
role="button"
aria-disabled={disabled}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

aria-disabled={disabled} is always false here. The Pressable only renders when interactive is true, and interactive already excludes disabled. So with disabled and onItemPress set, the item button disappears from the accessibility tree and is not announced as a disabled button. Render the Pressable whenever onItemPress is set, and pass disabled to it. (via Claude, verify if it makes sense)

Comment on lines +217 to +220
onFocus={(event) => {
if (!isKeyboardFocusEvent(event)) return;
setFocused(true);
}}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Keyboard focus does not move the carousel. On web with onItemPress set, Tab moves the focus ring onto the collapsed medium and small items at the edge, and the carousel stays where it is. The focused item is then a narrow strip with its label faded out. Scroll the focused item onto the focal keyline when it gets keyboard focus, for example with a callback from Carousel that calls scrollToIndex(index) from this onFocus. (via Claude, verify if it makes sense)

/**
* The item's live mask, from `renderItem`.
*/
mask: CarouselItemMask;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Callers have to pass mask from renderItem into each CarouselItemContent. The shell already owns the mask, so it can provide it through a context and CarouselItemContent can read it from there. That removes the prop drilling and the chance of passing the mask of another item. (via Claude, verify if it makes sense)

const animatedStyle = useAnimatedStyle(() => {
const { left, right } = mask.rect.value;
return {
transform: [{ translateX: direction === 'rtl' ? right - width : left }],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This RTL branch uses right - width, where width is the full item content width and not the width of this view. The view has no fixed width (left: 0, bottom: 0), so at full expansion the translation is 0 and the content stays at the left edge. As the mask collapses, the value goes negative and moves the content out of the mask. The Carousel docs also say RTL is not handled. Remove the branch until RTL is supported, or measure this view and use its own width. (via Claude, verify if it makes sense)

Comment on lines +112 to +114
keyExtractor?: (item: ItemT, index: number) => string;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

keyExtractor, style, contentContainerStyle and testID have no description. CONTRIBUTING asks for one on every prop, because the docs pages are generated from them.

contentContainerStyle also exposes an internal view. Its width is computed from the keylines (strategy.contentSize) and the consumer style merges after it, so a caller width or padding changes the scroll range without changing maxScroll or the snap targets. Remove contentContainerStyle, and document the other props. (via Claude, verify if it makes sense)

Comment on lines +480 to +491
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);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This test reads the component source files and checks that each token name appears in the text. It tests the implementation, and it still passes when a name only appears in a comment. The project test rules ask for public behavior. Remove it, or assert on rendered output, for example the border color when outlined is set. (via Claude, verify if it makes sense)

Comment thread example/node_modules
@@ -0,0 +1 @@
/Users/andrii.doroshenko/react-native-paper/example/node_modules No newline at end of file

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same as the root node_modules: this is a symlink to a path on the author's machine. In a fresh checkout both are dangling symlinks, and I had to delete them before yarn install worked. Remove this one too. (via Claude, verify if it makes sense)

@satya164

Copy link
Copy Markdown
Member

Review by Claude

iOS

The carousel is empty in RTL

  1. Turn on RTL in the example drawer and open the Carousel example.
  2. Swipe the multi-browse carousel.

What happens: before the swipe only Beach shows, pinned to the right edge. After the swipe the carousel is empty, and "Focused item" still changes. The hero carousels also show only one item.

What should happen: the items render and scroll, left to right as the docs say.

iOS, RTL: one item before a swipe, nothing after (via Claude, verify if it makes sense)

Reduce Motion stops every settle

  1. Turn on Reduce Motion and relaunch the app.
  2. Press Next, or fling the multi-browse carousel.

What happens: Next changes "Focused item" to City, but the carousel stays on Beach. A fling stops where the finger lifts, between two keylines.

What should happen: the carousel jumps to the target item without the spring.

iOS, Reduce Motion: Focused item says City, the carousel is still on Beach (via Claude, verify if it makes sense)

Android

The carousel is empty in RTL

Same steps as on iOS. All carousels on the screen are empty. The multi-browse carousel stays empty after a swipe, while "Focused item" changes to Strawberries.

Android, RTL: the carousels are empty (via Claude, verify if it makes sense)

Reduce Motion stops every settle

With animations turned off in developer options, Next changes "Focused item" to Bridge and the carousel stays on Beach.

Android, animations off: Focused item says Bridge, the carousel is still on Beach (via Claude, verify if it makes sense)

Web

The carousel layout breaks in RTL

With RTL on, the multi-browse items bunch up on the left half and the hero carousels are empty.

Web, RTL and dark theme: items bunched on the left, hero carousels empty (via Claude, verify if it makes sense)

Mouse wheel, trackpad and touch cannot scroll the carousel

  1. Open the Carousel example in a browser.
  2. Scroll the multi-browse carousel sideways with a trackpad or Shift and the mouse wheel.

What happens: nothing moves. The scroll element has overflow-x: hidden, so only Previous and Next can move it.

What should happen: the carousel scrolls and then settles on the nearest item. (via Claude, verify if it makes sense)

Reduce Motion stops every settle

With prefers-reduced-motion: reduce, Next changes "Focused item" to Bridge and scrollLeft stays at 0. (via Claude, verify if it makes sense)

@satya164 satya164 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review by Claude

See the inline comments. Screenshots and steps are in #5123 (comment). (via Claude, verify if it makes sense)

Comment on lines +262 to +271
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]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

With Reduce Motion on, the carousel never moves. withSpring with ReduceMotion.Always finishes in its first frame, so this callback sets settling back to false before the reaction below runs. The reaction then reads null and never calls scrollTo. Next and Previous report the new index through onIndexChange while the carousel stays where it was, and a fling stops between two keylines. This happens on iOS (Reduce Motion), Android (animations off) and web (prefers-reduced-motion):

iOS, Reduce Motion: Focused item says City, the carousel is still on Beach

Write the target offset when the spring finishes. With this change, Next and flings land on the target item on all three platforms:

Suggested change
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]
settle.value = withSpring(target, springConfig, (finished) => {
if (finished) {
scrollTo(scrollRef, target, 0, false);
settling.value = false;
if (itemSize > 0) {
runOnJS(reportIndex)(Math.round(target / itemSize));
}
}
});
},
[springConfig, itemSize, reportIndex, settle, settling, scrollX, scrollRef]

(via Claude, verify if it makes sense)

onScroll={scrollHandler}
onMomentumScrollEnd={handleMomentumEnd}
scrollEventThrottle={16}
aria-label={ariaLabel}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The aria-label does not reach any screen reader. React Native's ScrollView does not map aria-label to accessibilityLabel like View does, so the native scroll view has no label on iOS and an empty content-desc on Android. On web the label lands on a div with no role, and ARIA ignores names on generic elements. With this change the iOS tree shows "Photos" on the scroll view, the Android dump shows content-desc="Photos", and web renders <section role="region" aria-label="Photos">:

Suggested change
aria-label={ariaLabel}
accessibilityLabel={ariaLabel}
role="region"

(via Claude, verify if it makes sense)

Comment on lines +434 to +438
const styles = StyleSheet.create({
container: {
overflow: 'hidden',
},
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

On web nobody can scroll the carousel by hand. This style overrides the overflow-x: auto that react-native-web gives a horizontal ScrollView, so the scroll element is overflow-x: hidden. Wheel, trackpad and touch scrolling do nothing, and only scrollToIndex moves it. Native scroll views already clip their content: without overflow: 'hidden' the items still clip at the container edges on iOS and Android.

After you remove it, a second problem shows: wheel and trackpad scrolls on web fire no onScrollBeginDrag or onScrollEndDrag, so the carousel stops between two keylines and onIndexChange never fires. Settle once the scroll events stop on web. A 120 ms debounce on the scroll element's scroll event that calls scrollToIndex(Math.round(scrollLeft / itemSize)) springs it onto the nearest item and reports the index. (via Claude, verify if it makes sense)

*
* The item corner radius comes from `theme.shapes.corner.extraLarge`.
*
* Note: layout is left-to-right. Right-to-left locales are not handled yet.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

RTL is worse than left-to-right layout: the carousel renders nothing. The item boxes sit at left: 0 and move by translateX: scrollX + center, which only lines up with a left-to-right scroll view. On iOS only one item shows at first and the carousel is empty after a swipe. On Android it is empty from the start. On web the items bunch up on the left and the hero carousels are empty.

iOS, RTL

Android, RTL

Web, RTL

Until RTL is supported, force the scroll view to left-to-right so the note here is true: add direction: 'ltr' to styles.container for iOS and Android, and dir="ltr" for web, because react-native-web ignores the direction style. With both, the carousels render and scroll left to right on all three platforms with RTL on. (via Claude, verify if it makes sense)

@Doberjohn Doberjohn added this to the 6.1.0 milestone Oct 30, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown

Found potential problems with the pull request:

  • The description is too long. Please keep it under 1000 characters.
  • Screenshot or video evidence is missing. Make sure to include one if it affects the UI.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants